From 1bfb0a24d843e10d6d95c4319a84980485e584ed Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 9 Jun 2021 07:33:54 +0800 Subject: Refactor routers directory (#15800) * refactor routers directory * move func used for web and api to common * make corsHandler a function to prohibit side efects * rm unused func Co-authored-by: 6543 <6543@obermui.de> --- routers/web/admin/admin.go | 485 ++++++++++++++++++++++++++++++++++++++++ routers/web/admin/admin_test.go | 69 ++++++ routers/web/admin/auths.go | 410 +++++++++++++++++++++++++++++++++ routers/web/admin/emails.go | 156 +++++++++++++ routers/web/admin/hooks.go | 72 ++++++ routers/web/admin/main_test.go | 16 ++ routers/web/admin/notice.go | 79 +++++++ routers/web/admin/orgs.go | 34 +++ routers/web/admin/repos.go | 166 ++++++++++++++ routers/web/admin/users.go | 371 ++++++++++++++++++++++++++++++ routers/web/admin/users_test.go | 123 ++++++++++ 11 files changed, 1981 insertions(+) create mode 100644 routers/web/admin/admin.go create mode 100644 routers/web/admin/admin_test.go create mode 100644 routers/web/admin/auths.go create mode 100644 routers/web/admin/emails.go create mode 100644 routers/web/admin/hooks.go create mode 100644 routers/web/admin/main_test.go create mode 100644 routers/web/admin/notice.go create mode 100644 routers/web/admin/orgs.go create mode 100644 routers/web/admin/repos.go create mode 100644 routers/web/admin/users.go create mode 100644 routers/web/admin/users_test.go (limited to 'routers/web/admin') diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go new file mode 100644 index 0000000000..c2d94ab9c9 --- /dev/null +++ b/routers/web/admin/admin.go @@ -0,0 +1,485 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "fmt" + "net/http" + "net/url" + "os" + "runtime" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/cron" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + jsoniter "github.com/json-iterator/go" + + "gitea.com/go-chi/session" +) + +const ( + tplDashboard base.TplName = "admin/dashboard" + tplConfig base.TplName = "admin/config" + tplMonitor base.TplName = "admin/monitor" + tplQueue base.TplName = "admin/queue" +) + +var sysStatus struct { + Uptime 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) + LastGC string // last run in absolute time (ns) + PauseTotalNs string + PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256] + NumGC uint32 +} + +func updateSystemStatus() { + sysStatus.Uptime = timeutil.TimeSincePro(setting.AppStartTime, "en") + + 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.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000) + 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 +} + +// Dashboard show admin panel dashboard +func Dashboard(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.dashboard") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminDashboard"] = true + ctx.Data["Stats"] = models.GetStatistic() + // FIXME: update periodically + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + ctx.Data["SSH"] = setting.SSH + ctx.HTML(http.StatusOK, tplDashboard) +} + +// 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["PageIsAdmin"] = true + ctx.Data["PageIsAdminDashboard"] = true + ctx.Data["Stats"] = models.GetStatistic() + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + + // Run operation. + if form.Op != "" { + task := cron.GetTask(form.Op) + if task != nil { + go task.RunWithUser(ctx.User, 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") + } else { + ctx.Redirect(setting.AppSubURL + "/admin") + } +} + +// SendTestMail send test mail to confirm mail service is OK +func SendTestMail(ctx *context.Context) { + email := ctx.Query("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") +} + +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") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminConfig"] = true + + ctx.Data["CustomConf"] = setting.CustomConf + ctx.Data["AppUrl"] = setting.AppURL + ctx.Data["Domain"] = setting.Domain + ctx.Data["OfflineMode"] = setting.OfflineMode + ctx.Data["DisableRouterLog"] = setting.DisableRouterLog + ctx.Data["RunUser"] = setting.RunUser + ctx.Data["RunMode"] = strings.Title(setting.RunMode) + if version, err := git.LocalVersion(); err == nil { + ctx.Data["GitVersion"] = version.Original() + } + ctx.Data["RepoRootPath"] = setting.RepoRootPath + ctx.Data["CustomRootPath"] = setting.CustomPath + ctx.Data["StaticRootPath"] = setting.StaticRootPath + ctx.Data["LogRootPath"] = setting.LogRootPath + 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 + json := jsoniter.ConfigCompatibleWithStandardLibrary + if err := json.Unmarshal([]byte(sessionCfg.ProviderConfig), &realSession); err != nil { + log.Error("Unable to unmarshall session config for virtualed 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["DisableGravatar"] = setting.DisableGravatar + ctx.Data["EnableFederatedAvatar"] = setting.EnableFederatedAvatar + + ctx.Data["Git"] = setting.Git + + type envVar struct { + Name, Value string + } + + envVars := map[string]*envVar{} + if len(os.Getenv("GITEA_WORK_DIR")) > 0 { + envVars["GITEA_WORK_DIR"] = &envVar{"GITEA_WORK_DIR", os.Getenv("GITEA_WORK_DIR")} + } + if len(os.Getenv("GITEA_CUSTOM")) > 0 { + envVars["GITEA_CUSTOM"] = &envVar{"GITEA_CUSTOM", os.Getenv("GITEA_CUSTOM")} + } + + ctx.Data["EnvVars"] = envVars + ctx.Data["Loggers"] = setting.GetLogDescriptions() + ctx.Data["EnableAccessLog"] = setting.EnableAccessLog + ctx.Data["AccessLogTemplate"] = setting.AccessLogTemplate + ctx.Data["DisableRouterLog"] = setting.DisableRouterLog + ctx.Data["EnableXORMLog"] = setting.EnableXORMLog + ctx.Data["LogSQL"] = setting.Database.LogSQL + + ctx.HTML(http.StatusOK, tplConfig) +} + +// Monitor show admin monitor page +func Monitor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminMonitor"] = true + ctx.Data["Processes"] = process.GetManager().Processes() + ctx.Data["Entries"] = cron.ListTasks() + ctx.Data["Queues"] = queue.GetManager().ManagedQueues() + ctx.HTML(http.StatusOK, tplMonitor) +} + +// MonitorCancel cancels a process +func MonitorCancel(ctx *context.Context) { + pid := ctx.ParamsInt64("pid") + process.GetManager().Cancel(pid) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/monitor", + }) +} + +// Queue shows details for a specific queue +func Queue(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(404) + return + } + ctx.Data["Title"] = ctx.Tr("admin.monitor.queue", mq.Name) + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminMonitor"] = true + ctx.Data["Queue"] = mq + ctx.HTML(http.StatusOK, tplQueue) +} + +// WorkerCancel cancels a worker group +func WorkerCancel(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(404) + return + } + pid := ctx.ParamsInt64("pid") + mq.CancelWorkers(pid) + ctx.Flash.Info(ctx.Tr("admin.monitor.queue.pool.cancelling")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10), + }) +} + +// Flush flushes a queue +func Flush(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(404) + return + } + timeout, err := time.ParseDuration(ctx.Query("timeout")) + if err != nil { + timeout = -1 + } + ctx.Flash.Info(ctx.Tr("admin.monitor.queue.pool.flush.added", mq.Name)) + go func() { + err := mq.Flush(timeout) + if err != nil { + log.Error("Flushing failure for %s: Error %v", mq.Name, err) + } + }() + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) +} + +// AddWorkers adds workers to a worker group +func AddWorkers(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(404) + return + } + number := ctx.QueryInt("number") + if number < 1 { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.pool.addworkers.mustnumbergreaterzero")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + timeout, err := time.ParseDuration(ctx.Query("timeout")) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.pool.addworkers.musttimeoutduration")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + if _, ok := mq.Managed.(queue.ManagedPool); !ok { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.pool.none")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + mq.AddWorkers(number, timeout) + ctx.Flash.Success(ctx.Tr("admin.monitor.queue.pool.added")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) +} + +// SetQueueSettings sets the maximum number of workers and other settings for this queue +func SetQueueSettings(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(404) + return + } + if _, ok := mq.Managed.(queue.ManagedPool); !ok { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.pool.none")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + + maxNumberStr := ctx.Query("max-number") + numberStr := ctx.Query("number") + timeoutStr := ctx.Query("timeout") + + var err error + var maxNumber, number int + var timeout time.Duration + 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.MaxNumberOfWorkers() + } + + if len(numberStr) > 0 { + number, err = strconv.Atoi(numberStr) + if err != nil || number < 0 { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.settings.numberworkers.error")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + } else { + number = mq.BoostWorkers() + } + + if len(timeoutStr) > 0 { + timeout, err = time.ParseDuration(timeoutStr) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.settings.timeout.error")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + } else { + timeout = mq.BoostTimeout() + } + + mq.SetPoolSettings(maxNumber, number, timeout) + ctx.Flash.Success(ctx.Tr("admin.monitor.queue.settings.changed")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) +} diff --git a/routers/web/admin/admin_test.go b/routers/web/admin/admin_test.go new file mode 100644 index 0000000000..da404e50d7 --- /dev/null +++ b/routers/web/admin/admin_test.go @@ -0,0 +1,69 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShadowPassword(t *testing.T) { + var 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)) + } +} diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go new file mode 100644 index 0000000000..a2f9ab0a5c --- /dev/null +++ b/routers/web/admin/auths.go @@ -0,0 +1,410 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "errors" + "fmt" + "net/http" + "regexp" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/ldap" + "code.gitea.io/gitea/modules/auth/oauth2" + "code.gitea.io/gitea/modules/auth/pam" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "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["PageIsAdmin"] = true + ctx.Data["PageIsAdminAuthentications"] = true + + var err error + ctx.Data["Sources"], err = models.LoginSources() + if err != nil { + ctx.ServerError("LoginSources", err) + return + } + + ctx.Data["Total"] = models.CountLoginSources() + ctx.HTML(http.StatusOK, tplAuths) +} + +type dropdownItem struct { + Name string + Type interface{} +} + +var ( + authSources = func() []dropdownItem { + items := []dropdownItem{ + {models.LoginNames[models.LoginLDAP], models.LoginLDAP}, + {models.LoginNames[models.LoginDLDAP], models.LoginDLDAP}, + {models.LoginNames[models.LoginSMTP], models.LoginSMTP}, + {models.LoginNames[models.LoginOAuth2], models.LoginOAuth2}, + {models.LoginNames[models.LoginSSPI], models.LoginSSPI}, + } + if pam.Supported { + items = append(items, dropdownItem{models.LoginNames[models.LoginPAM], models.LoginPAM}) + } + return items + }() + + securityProtocols = []dropdownItem{ + {models.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted}, + {models.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, + {models.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["PageIsAdmin"] = true + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["type"] = models.LoginLDAP + ctx.Data["CurrentTypeName"] = models.LoginNames[models.LoginLDAP] + ctx.Data["CurrentSecurityProtocol"] = models.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"] = models.SMTPAuths + ctx.Data["OAuth2Providers"] = models.OAuth2Providers + ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings + + ctx.Data["SSPIAutoCreateUsers"] = true + ctx.Data["SSPIAutoActivateUsers"] = true + ctx.Data["SSPIStripDomainNames"] = true + ctx.Data["SSPISeparatorReplacement"] = "_" + ctx.Data["SSPIDefaultLanguage"] = "" + + // only the first as default + for key := range models.OAuth2Providers { + ctx.Data["oauth2_provider"] = key + break + } + + ctx.HTML(http.StatusOK, tplAuthNew) +} + +func parseLDAPConfig(form forms.AuthenticationForm) *models.LDAPConfig { + var pageSize uint32 + if form.UsePagedSearch { + pageSize = uint32(form.SearchPageSize) + } + return &models.LDAPConfig{ + Source: &ldap.Source{ + Name: form.Name, + Host: form.Host, + Port: form.Port, + SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol), + SkipVerify: form.SkipVerify, + BindDN: form.BindDN, + UserDN: form.UserDN, + BindPassword: form.BindPassword, + UserBase: form.UserBase, + AttributeUsername: form.AttributeUsername, + AttributeName: form.AttributeName, + AttributeSurname: form.AttributeSurname, + AttributeMail: form.AttributeMail, + AttributesInBind: form.AttributesInBind, + AttributeSSHPublicKey: form.AttributeSSHPublicKey, + SearchPageSize: pageSize, + Filter: form.Filter, + GroupsEnabled: form.GroupsEnabled, + GroupDN: form.GroupDN, + GroupFilter: form.GroupFilter, + GroupMemberUID: form.GroupMemberUID, + UserUID: form.UserUID, + AdminFilter: form.AdminFilter, + RestrictedFilter: form.RestrictedFilter, + AllowDeactivateAll: form.AllowDeactivateAll, + Enabled: true, + }, + } +} + +func parseSMTPConfig(form forms.AuthenticationForm) *models.SMTPConfig { + return &models.SMTPConfig{ + Auth: form.SMTPAuth, + Host: form.SMTPHost, + Port: form.SMTPPort, + AllowedDomains: form.AllowedDomains, + TLS: form.TLS, + SkipVerify: form.SkipVerify, + } +} + +func parseOAuth2Config(form forms.AuthenticationForm) *models.OAuth2Config { + var customURLMapping *oauth2.CustomURLMapping + if form.Oauth2UseCustomURL { + customURLMapping = &oauth2.CustomURLMapping{ + TokenURL: form.Oauth2TokenURL, + AuthURL: form.Oauth2AuthURL, + ProfileURL: form.Oauth2ProfileURL, + EmailURL: form.Oauth2EmailURL, + } + } else { + customURLMapping = nil + } + return &models.OAuth2Config{ + Provider: form.Oauth2Provider, + ClientID: form.Oauth2Key, + ClientSecret: form.Oauth2Secret, + OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL, + CustomURLMapping: customURLMapping, + IconURL: form.Oauth2IconURL, + } +} + +func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*models.SSPIConfig, error) { + if util.IsEmptyString(form.SSPISeparatorReplacement) { + ctx.Data["Err_SSPISeparatorReplacement"] = true + return nil, errors.New(ctx.Tr("form.SSPISeparatorReplacement") + ctx.Tr("form.require_error")) + } + if separatorAntiPattern.MatchString(form.SSPISeparatorReplacement) { + ctx.Data["Err_SSPISeparatorReplacement"] = true + return nil, errors.New(ctx.Tr("form.SSPISeparatorReplacement") + ctx.Tr("form.alpha_dash_dot_error")) + } + + if form.SSPIDefaultLanguage != "" && !langCodePattern.MatchString(form.SSPIDefaultLanguage) { + ctx.Data["Err_SSPIDefaultLanguage"] = true + return nil, errors.New(ctx.Tr("form.lang_select_error")) + } + + return &models.SSPIConfig{ + 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["PageIsAdmin"] = true + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["CurrentTypeName"] = models.LoginNames[models.LoginType(form.Type)] + ctx.Data["CurrentSecurityProtocol"] = models.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)] + ctx.Data["AuthSources"] = authSources + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = models.SMTPAuths + ctx.Data["OAuth2Providers"] = models.OAuth2Providers + ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings + + 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 models.LoginType(form.Type) { + case models.LoginLDAP, models.LoginDLDAP: + config = parseLDAPConfig(form) + hasTLS = ldap.SecurityProtocol(form.SecurityProtocol) > ldap.SecurityProtocolUnencrypted + case models.LoginSMTP: + config = parseSMTPConfig(form) + hasTLS = true + case models.LoginPAM: + config = &models.PAMConfig{ + ServiceName: form.PAMServiceName, + EmailDomain: form.PAMEmailDomain, + } + case models.LoginOAuth2: + config = parseOAuth2Config(form) + case models.LoginSSPI: + var err error + config, err = parseSSPIConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthNew, form) + return + } + existing, err := models.LoginSourcesByType(models.LoginSSPI) + 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 := models.CreateLoginSource(&models.LoginSource{ + Type: models.LoginType(form.Type), + Name: form.Name, + IsActived: form.IsActive, + IsSyncEnabled: form.IsSyncEnabled, + Cfg: config, + }); err != nil { + if models.IsErrLoginSourceAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(models.ErrLoginSourceAlreadyExist).Name), tplAuthNew, form) + } else { + ctx.ServerError("CreateSource", err) + } + return + } + + log.Trace("Authentication created by admin(%s): %s", ctx.User.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["PageIsAdmin"] = true + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = models.SMTPAuths + ctx.Data["OAuth2Providers"] = models.OAuth2Providers + ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings + + source, err := models.GetLoginSourceByID(ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("GetLoginSourceByID", err) + return + } + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + + if source.IsOAuth2() { + ctx.Data["CurrentOAuth2Provider"] = models.OAuth2Providers[source.OAuth2().Provider] + } + 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["PageIsAdmin"] = true + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["SMTPAuths"] = models.SMTPAuths + ctx.Data["OAuth2Providers"] = models.OAuth2Providers + ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings + + source, err := models.GetLoginSourceByID(ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("GetLoginSourceByID", 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 models.LoginType(form.Type) { + case models.LoginLDAP, models.LoginDLDAP: + config = parseLDAPConfig(form) + case models.LoginSMTP: + config = parseSMTPConfig(form) + case models.LoginPAM: + config = &models.PAMConfig{ + ServiceName: form.PAMServiceName, + EmailDomain: form.PAMEmailDomain, + } + case models.LoginOAuth2: + config = parseOAuth2Config(form) + case models.LoginSSPI: + 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.IsActived = form.IsActive + source.IsSyncEnabled = form.IsSyncEnabled + source.Cfg = config + if err := models.UpdateSource(source); err != nil { + if models.IsErrOpenIDConnectInitialize(err) { + ctx.Flash.Error(err.Error(), true) + ctx.HTML(http.StatusOK, tplAuthEdit) + } else { + ctx.ServerError("UpdateSource", err) + } + return + } + log.Trace("Authentication changed by admin(%s): %d", ctx.User.Name, source.ID) + + ctx.Flash.Success(ctx.Tr("admin.auths.update_success")) + ctx.Redirect(setting.AppSubURL + "/admin/auths/" + fmt.Sprint(form.ID)) +} + +// DeleteAuthSource response for deleting an auth source +func DeleteAuthSource(ctx *context.Context) { + source, err := models.GetLoginSourceByID(ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("GetLoginSourceByID", err) + return + } + + if err = models.DeleteSource(source); err != nil { + if models.IsErrLoginSourceInUse(err) { + ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used")) + } else { + ctx.Flash.Error(fmt.Sprintf("DeleteSource: %v", err)) + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/auths/" + ctx.Params(":authid"), + }) + return + } + log.Trace("Authentication deleted by admin(%s): %d", ctx.User.Name, source.ID) + + ctx.Flash.Success(ctx.Tr("admin.auths.deletion_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/auths", + }) +} diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go new file mode 100644 index 0000000000..f7e8c97fb6 --- /dev/null +++ b/routers/web/admin/emails.go @@ -0,0 +1,156 @@ +// Copyright 2020 The Gitea Authors. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "bytes" + "net/http" + "net/url" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +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["PageIsAdmin"] = true + ctx.Data["PageIsAdminEmails"] = true + + opts := &models.SearchEmailOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.QueryInt("page"), + }, + } + + if opts.Page <= 1 { + opts.Page = 1 + } + + type ActiveEmail struct { + models.SearchEmailResult + CanChange bool + } + + var ( + baseEmails []*models.SearchEmailResult + emails []ActiveEmail + count int64 + err error + orderBy models.SearchEmailOrderBy + ) + + ctx.Data["SortType"] = ctx.Query("sort") + switch ctx.Query("sort") { + case "email": + orderBy = models.SearchEmailOrderByEmail + case "reverseemail": + orderBy = models.SearchEmailOrderByEmailReverse + case "username": + orderBy = models.SearchEmailOrderByName + case "reverseusername": + orderBy = models.SearchEmailOrderByNameReverse + default: + ctx.Data["SortType"] = "email" + orderBy = models.SearchEmailOrderByEmail + } + + opts.Keyword = ctx.QueryTrim("q") + opts.SortType = orderBy + if len(ctx.Query("is_activated")) != 0 { + opts.IsActivated = util.OptionalBoolOf(ctx.QueryBool("activated")) + } + if len(ctx.Query("is_primary")) != 0 { + opts.IsPrimary = util.OptionalBoolOf(ctx.QueryBool("primary")) + } + + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + baseEmails, count, err = models.SearchEmails(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.User.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.QueryInt64("uid") + email := ctx.Query("email") + primary, okp := truefalse[ctx.Query("primary")] + activate, oka := truefalse[ctx.Query("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 := models.ActivateUserEmail(uid, email, primary, activate); err != nil { + log.Error("ActivateUserEmail(%v,%v,%v,%v): %v", uid, email, primary, activate, err) + if models.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.QueryTrim("q"); len(val) > 0 { + q.Set("q", val) + } + if val := ctx.QueryTrim("sort"); len(val) > 0 { + q.Set("sort", val) + } + if val := ctx.QueryTrim("is_primary"); len(val) > 0 { + q.Set("is_primary", val) + } + if val := ctx.QueryTrim("is_activated"); len(val) > 0 { + q.Set("is_activated", val) + } + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String()) +} diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go new file mode 100644 index 0000000000..ff32260cc0 --- /dev/null +++ b/routers/web/admin/hooks.go @@ -0,0 +1,72 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +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["PageIsAdminSystemHooks"] = true + ctx.Data["PageIsAdminDefaultHooks"] = true + + def := make(map[string]interface{}, len(ctx.Data)) + sys := make(map[string]interface{}, 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") + sys["Webhooks"], err = models.GetSystemWebhooks() + sys["BaseLink"] = setting.AppSubURL + "/admin/hooks" + sys["BaseLinkNew"] = setting.AppSubURL + "/admin/system-hooks" + if err != nil { + ctx.ServerError("GetWebhooksAdmin", err) + return + } + + def["Title"] = ctx.Tr("admin.defaulthooks") + def["Description"] = ctx.Tr("admin.defaulthooks.desc") + def["Webhooks"], err = models.GetDefaultWebhooks() + def["BaseLink"] = setting.AppSubURL + "/admin/hooks" + def["BaseLinkNew"] = setting.AppSubURL + "/admin/default-hooks" + if err != nil { + ctx.ServerError("GetWebhooksAdmin", err) + return + } + + ctx.Data["DefaultWebhooks"] = def + ctx.Data["SystemWebhooks"] = sys + + ctx.HTML(http.StatusOK, tplAdminHooks) +} + +// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook +func DeleteDefaultOrSystemWebhook(ctx *context.Context) { + if err := models.DeleteDefaultSystemWebhook(ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": 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 0000000000..352907c737 --- /dev/null +++ b/routers/web/admin/main_test.go @@ -0,0 +1,16 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..", "..")) +} diff --git a/routers/web/admin/notice.go b/routers/web/admin/notice.go new file mode 100644 index 0000000000..e2ebd0d917 --- /dev/null +++ b/routers/web/admin/notice.go @@ -0,0 +1,79 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +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["PageIsAdmin"] = true + ctx.Data["PageIsAdminNotices"] = true + + total := models.CountNotices() + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + notices, err := models.Notices(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.QueryStrings("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 := models.DeleteNoticesByIDs(ids); err != nil { + ctx.Flash.Error("DeleteNoticesByIDs: " + err.Error()) + ctx.Status(500) + } else { + ctx.Flash.Success(ctx.Tr("admin.notices.delete_success")) + ctx.Status(200) + } +} + +// EmptyNotices delete all the notices +func EmptyNotices(ctx *context.Context) { + if err := models.DeleteNotices(0, 0); err != nil { + ctx.ServerError("DeleteNotices", err) + return + } + + log.Trace("System notices deleted by admin (%s): [start: %d]", ctx.User.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 0000000000..618f945704 --- /dev/null +++ b/routers/web/admin/orgs.go @@ -0,0 +1,34 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/web/explore" +) + +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["PageIsAdmin"] = true + ctx.Data["PageIsAdminOrganizations"] = true + + explore.RenderUserSearch(ctx, &models.SearchUserOptions{ + Type: models.UserTypeOrganization, + ListOptions: models.ListOptions{ + PageSize: setting.UI.Admin.OrgPagingNum, + }, + Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + }, tplOrgs) +} diff --git a/routers/web/admin/repos.go b/routers/web/admin/repos.go new file mode 100644 index 0000000000..6128992f5a --- /dev/null +++ b/routers/web/admin/repos.go @@ -0,0 +1,166 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/explore" + 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["PageIsAdmin"] = true + ctx.Data["PageIsAdminRepositories"] = true + + explore.RenderRepoSearch(ctx, &explore.RepoSearchOptions{ + Private: true, + PageSize: setting.UI.Admin.RepoPagingNum, + TplName: tplRepos, + }) +} + +// DeleteRepo delete one repository +func DeleteRepo(ctx *context.Context) { + repo, err := models.GetRepositoryByID(ctx.QueryInt64("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.User, repo); err != nil { + ctx.ServerError("DeleteRepository", err) + return + } + log.Trace("Repository deleted: %s", repo.FullName()) + + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/repos?page=" + ctx.Query("page") + "&sort=" + ctx.Query("sort"), + }) +} + +// UnadoptedRepos lists the unadopted repositories +func UnadoptedRepos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.repositories") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminRepositories"] = true + + opts := models.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.QueryInt("page"), + } + + if opts.Page <= 0 { + opts.Page = 1 + } + + ctx.Data["CurrentPage"] = opts.Page + + doSearch := ctx.QueryBool("search") + + ctx.Data["search"] = doSearch + q := ctx.Query("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 := repository.ListUnadoptedRepositories(q, &opts) + if err != nil { + ctx.ServerError("ListUnadoptedRepositories", err) + } + ctx.Data["Dirs"] = repoNames + pager := context.NewPagination(int(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.Query("id") + action := ctx.Query("action") + page := ctx.QueryInt("page") + q := ctx.Query("q") + + dirSplit := strings.SplitN(dir, "/", 2) + if len(dirSplit) != 2 { + ctx.Redirect(setting.AppSubURL + "/admin/repos") + return + } + + ctxUser, err := models.GetUserByName(dirSplit[0]) + if err != nil { + if models.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 := models.IsRepositoryExist(ctxUser, repoName) + if err != nil { + ctx.ServerError("IsRepositoryExist", err) + return + } + isDir, err := util.IsDir(models.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 := repository.AdoptRepository(ctx.User, ctxUser, models.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 := repository.DeleteUnadoptedRepository(ctx.User, 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=" + strconv.Itoa(page)) +} diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go new file mode 100644 index 0000000000..1b65795865 --- /dev/null +++ b/routers/web/admin/users.go @@ -0,0 +1,371 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/password" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/explore" + router_user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" +) + +const ( + tplUsers base.TplName = "admin/user/list" + tplUserNew base.TplName = "admin/user/new" + tplUserEdit base.TplName = "admin/user/edit" +) + +// Users show all the users +func Users(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminUsers"] = true + + explore.RenderUserSearch(ctx, &models.SearchUserOptions{ + Type: models.UserTypeIndividual, + ListOptions: models.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + }, + SearchByEmail: true, + }, tplUsers) +} + +// NewUser render adding a new user page +func NewUser(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.new_account") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminUsers"] = true + + ctx.Data["login_type"] = "0-0" + + sources, err := models.LoginSources() + if err != nil { + ctx.ServerError("LoginSources", 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["PageIsAdmin"] = true + ctx.Data["PageIsAdminUsers"] = true + + sources, err := models.LoginSources() + if err != nil { + ctx.ServerError("LoginSources", err) + return + } + ctx.Data["Sources"] = sources + + ctx.Data["CanSendEmail"] = setting.MailService != nil + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUserNew) + return + } + + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + IsActive: true, + LoginType: models.LoginPlain, + } + + if len(form.LoginType) > 0 { + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + lType, _ := strconv.ParseInt(fields[0], 10, 0) + u.LoginType = models.LoginType(lType) + u.LoginSource, _ = strconv.ParseInt(fields[1], 10, 64) + u.LoginName = form.LoginName + } + } + if u.LoginType == models.LoginNoType || u.LoginType == models.LoginPlain { + 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), tplUserNew, &form) + return + } + pwned, err := password.IsPwned(ctx, form.Password) + if pwned { + ctx.Data["Err_Password"] = true + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.RenderWithErr(errMsg, tplUserNew, &form) + return + } + u.MustChangePassword = form.MustChangePassword + } + if err := models.CreateUser(u); err != nil { + switch { + case models.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserNew, &form) + case models.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserNew, &form) + case models.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserNew, &form) + case models.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(models.ErrNameReserved).Name), tplUserNew, &form) + case models.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tplUserNew, &form) + case models.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(models.ErrNameCharsNotAllowed).Name), tplUserNew, &form) + default: + ctx.ServerError("CreateUser", err) + } + return + } + log.Trace("Account created by admin (%s): %s", ctx.User.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/" + fmt.Sprint(u.ID)) +} + +func prepareUserInfo(ctx *context.Context) *models.User { + u, err := models.GetUserByID(ctx.ParamsInt64(":userid")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return nil + } + ctx.Data["User"] = u + + if u.LoginSource > 0 { + ctx.Data["LoginSource"], err = models.GetLoginSourceByID(u.LoginSource) + if err != nil { + ctx.ServerError("GetLoginSourceByID", err) + return nil + } + } else { + ctx.Data["LoginSource"] = &models.LoginSource{} + } + + sources, err := models.LoginSources() + if err != nil { + ctx.ServerError("LoginSources", err) + return nil + } + ctx.Data["Sources"] = sources + + ctx.Data["TwoFactorEnabled"] = true + _, err = models.GetTwoFactorByUID(u.ID) + if err != nil { + if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("IsErrTwoFactorNotEnrolled", err) + return nil + } + ctx.Data["TwoFactorEnabled"] = false + } + + return u +} + +// EditUser show editting user page +func EditUser(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation + ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + + prepareUserInfo(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplUserEdit) +} + +// EditUserPost response for editting user +func EditUserPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminEditUserForm) + ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUserEdit) + return + } + + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + loginType, _ := strconv.ParseInt(fields[0], 10, 0) + loginSource, _ := strconv.ParseInt(fields[1], 10, 64) + + if u.LoginSource != loginSource { + u.LoginSource = loginSource + u.LoginType = models.LoginType(loginType) + } + } + + if len(form.Password) > 0 && (u.IsLocal() || u.IsOAuth2()) { + var err error + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form) + return + } + if !password.IsComplexEnough(form.Password) { + ctx.RenderWithErr(password.BuildComplexityError(ctx), tplUserEdit, &form) + return + } + pwned, err := password.IsPwned(ctx, form.Password) + if pwned { + ctx.Data["Err_Password"] = true + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.RenderWithErr(errMsg, tplUserNew, &form) + return + } + if u.Salt, err = models.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err = u.SetPassword(form.Password); err != nil { + ctx.ServerError("SetPassword", err) + return + } + } + + if len(form.UserName) != 0 && u.Name != form.UserName { + if err := router_user_setting.HandleUsernameChange(ctx, u, form.UserName); err != nil { + ctx.Redirect(setting.AppSubURL + "/admin/users") + return + } + u.Name = form.UserName + u.LowerName = strings.ToLower(form.UserName) + } + + if form.Reset2FA { + tf, err := models.GetTwoFactorByUID(u.ID) + if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("GetTwoFactorByUID", err) + return + } + + if err = models.DeleteTwoFactorByID(tf.ID, u.ID); err != nil { + ctx.ServerError("DeleteTwoFactorByID", err) + return + } + } + + u.LoginName = form.LoginName + u.FullName = form.FullName + u.Email = form.Email + u.Website = form.Website + u.Location = form.Location + u.MaxRepoCreation = form.MaxRepoCreation + u.IsActive = form.Active + u.IsAdmin = form.Admin + u.IsRestricted = form.Restricted + u.AllowGitHook = form.AllowGitHook + u.AllowImportLocal = form.AllowImportLocal + u.AllowCreateOrganization = form.AllowCreateOrganization + + // skip self Prohibit Login + if ctx.User.ID == u.ID { + u.ProhibitLogin = false + } else { + u.ProhibitLogin = form.ProhibitLogin + } + + if err := models.UpdateUser(u); err != nil { + if models.IsErrEmailAlreadyUsed(err) { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserEdit, &form) + } else if models.IsErrEmailInvalid(err) { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserEdit, &form) + } else { + ctx.ServerError("UpdateUser", err) + } + return + } + log.Trace("Account profile updated by admin (%s): %s", ctx.User.Name, u.Name) + + ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + ctx.Params(":userid")) +} + +// DeleteUser response for deleting a user +func DeleteUser(ctx *context.Context) { + u, err := models.GetUserByID(ctx.ParamsInt64(":userid")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + if err = models.DeleteUser(u); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_own_repo")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/users/" + ctx.Params(":userid"), + }) + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_has_org")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/users/" + ctx.Params(":userid"), + }) + default: + ctx.ServerError("DeleteUser", err) + } + return + } + log.Trace("Account deleted by admin (%s): %s", ctx.User.Name, u.Name) + + ctx.Flash.Success(ctx.Tr("admin.users.deletion_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/admin/users", + }) +} diff --git a/routers/web/admin/users_test.go b/routers/web/admin/users_test.go new file mode 100644 index 0000000000..b19dcb886b --- /dev/null +++ b/routers/web/admin/users_test.go @@ -0,0 +1,123 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" +) + +func TestNewUserPost_MustChangePassword(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "admin/users/new") + + u := models.AssertExistsAndLoadBean(t, &models.User{ + IsAdmin: true, + ID: 2, + }).(*models.User) + + ctx.User = 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 := models.GetUserByName(username) + + assert.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + assert.True(t, u.MustChangePassword) +} + +func TestNewUserPost_MustChangePasswordFalse(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "admin/users/new") + + u := models.AssertExistsAndLoadBean(t, &models.User{ + IsAdmin: true, + ID: 2, + }).(*models.User) + + ctx.User = 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 := models.GetUserByName(username) + + assert.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + assert.False(t, u.MustChangePassword) +} + +func TestNewUserPost_InvalidEmail(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "admin/users/new") + + u := models.AssertExistsAndLoadBean(t, &models.User{ + IsAdmin: true, + ID: 2, + }).(*models.User) + + ctx.User = 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) +} -- cgit v1.2.3