diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2021-06-09 07:33:54 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-06-09 01:33:54 +0200 |
commit | 1bfb0a24d843e10d6d95c4319a84980485e584ed (patch) | |
tree | e4a736f9abee3eaad1270bf3b60ee3bb9401a9dc /routers/web | |
parent | e03a91a48ef7fb716cc7c8bfb411ca8f332dcfe5 (diff) | |
download | gitea-1bfb0a24d843e10d6d95c4319a84980485e584ed.tar.gz gitea-1bfb0a24d843e10d6d95c4319a84980485e584ed.zip |
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>
Diffstat (limited to 'routers/web')
91 files changed, 28158 insertions, 0 deletions
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) +} diff --git a/routers/web/base.go b/routers/web/base.go new file mode 100644 index 0000000000..8a44736434 --- /dev/null +++ b/routers/web/base.go @@ -0,0 +1,189 @@ +// Copyright 2020 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 web + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/sso" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web/middleware" + + "gitea.com/go-chi/session" +) + +func storageHandler(storageSetting setting.Storage, prefix string, objStore storage.ObjectStorage) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if storageSetting.ServeDirect { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != "GET" && req.Method != "HEAD" { + next.ServeHTTP(w, req) + return + } + + if !strings.HasPrefix(req.URL.RequestURI(), "/"+prefix) { + next.ServeHTTP(w, req) + return + } + + rPath := strings.TrimPrefix(req.URL.RequestURI(), "/"+prefix) + u, err := objStore.URL(rPath, path.Base(rPath)) + if err != nil { + if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { + log.Warn("Unable to find %s %s", prefix, rPath) + http.Error(w, "file not found", 404) + return + } + log.Error("Error whilst getting URL for %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst getting URL for %s %s", prefix, rPath), 500) + return + } + http.Redirect( + w, + req, + u.String(), + 301, + ) + }) + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != "GET" && req.Method != "HEAD" { + next.ServeHTTP(w, req) + return + } + + prefix := strings.Trim(prefix, "/") + + if !strings.HasPrefix(req.URL.EscapedPath(), "/"+prefix+"/") { + next.ServeHTTP(w, req) + return + } + + rPath := strings.TrimPrefix(req.URL.EscapedPath(), "/"+prefix+"/") + rPath = strings.TrimPrefix(rPath, "/") + if rPath == "" { + http.Error(w, "file not found", 404) + return + } + rPath = path.Clean("/" + filepath.ToSlash(rPath)) + rPath = rPath[1:] + + fi, err := objStore.Stat(rPath) + if err == nil && httpcache.HandleTimeCache(req, w, fi) { + return + } + + //If we have matched and access to release or issue + fr, err := objStore.Open(rPath) + if err != nil { + if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { + log.Warn("Unable to find %s %s", prefix, rPath) + http.Error(w, "file not found", 404) + return + } + log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), 500) + return + } + defer fr.Close() + + _, err = io.Copy(w, fr) + if err != nil { + log.Error("Error whilst rendering %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst rendering %s %s", prefix, rPath), 500) + return + } + }) + } +} + +type dataStore map[string]interface{} + +func (d *dataStore) GetData() map[string]interface{} { + return *d +} + +// Recovery returns a middleware that recovers from any panics and writes a 500 and a log if so. +// This error will be created with the gitea 500 page. +func Recovery() func(next http.Handler) http.Handler { + var rnd = templates.HTMLRenderer() + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + defer func() { + if err := recover(); err != nil { + combinedErr := fmt.Sprintf("PANIC: %v\n%s", err, string(log.Stack(2))) + log.Error("%v", combinedErr) + + sessionStore := session.GetSession(req) + if sessionStore == nil { + if setting.IsProd() { + http.Error(w, http.StatusText(500), 500) + } else { + http.Error(w, combinedErr, 500) + } + return + } + + var lc = middleware.Locale(w, req) + var store = dataStore{ + "Language": lc.Language(), + "CurrentURL": setting.AppSubURL + req.URL.RequestURI(), + "i18n": lc, + } + + var user *models.User + if apiContext := context.GetAPIContext(req); apiContext != nil { + user = apiContext.User + } + if user == nil { + if ctx := context.GetContext(req); ctx != nil { + user = ctx.User + } + } + if user == nil { + // Get user from session if logged in - do not attempt to sign-in + user = sso.SessionUser(sessionStore) + } + if user != nil { + store["IsSigned"] = true + store["SignedUser"] = user + store["SignedUserID"] = user.ID + store["SignedUserName"] = user.Name + store["IsAdmin"] = user.IsAdmin + } else { + store["SignedUserID"] = int64(0) + store["SignedUserName"] = "" + } + + w.Header().Set(`X-Frame-Options`, `SAMEORIGIN`) + + if !setting.IsProd() { + store["ErrorMsg"] = combinedErr + } + err = rnd.HTML(w, 500, "status/500", templates.BaseVars().Merge(store)) + if err != nil { + log.Error("%v", err) + } + } + }() + + next.ServeHTTP(w, req) + }) + } +} diff --git a/routers/web/dev/template.go b/routers/web/dev/template.go new file mode 100644 index 0000000000..de334c4f8b --- /dev/null +++ b/routers/web/dev/template.go @@ -0,0 +1,29 @@ +// 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 dev + +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" + "code.gitea.io/gitea/modules/timeutil" +) + +// TemplatePreview render for previewing the indicated template +func TemplatePreview(ctx *context.Context) { + ctx.Data["User"] = models.User{Name: "Unknown"} + ctx.Data["AppName"] = setting.AppName + ctx.Data["AppVer"] = setting.AppVer + ctx.Data["AppUrl"] = setting.AppURL + ctx.Data["Code"] = "2014031910370000009fff6782aadb2162b4a997acb69d4400888e0b9274657374" + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()) + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale.Language()) + ctx.Data["CurDbValue"] = "" + + ctx.HTML(http.StatusOK, base.TplName(ctx.Params("*"))) +} diff --git a/routers/web/events/events.go b/routers/web/events/events.go new file mode 100644 index 0000000000..f9cc274851 --- /dev/null +++ b/routers/web/events/events.go @@ -0,0 +1,156 @@ +// Copyright 2020 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 events + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/web/user" + jsoniter "github.com/json-iterator/go" +) + +// Events listens for events +func Events(ctx *context.Context) { + // FIXME: Need to check if resp is actually a http.Flusher! - how though? + + // Set the headers related to event streaming. + ctx.Resp.Header().Set("Content-Type", "text/event-stream") + ctx.Resp.Header().Set("Cache-Control", "no-cache") + ctx.Resp.Header().Set("Connection", "keep-alive") + ctx.Resp.Header().Set("X-Accel-Buffering", "no") + ctx.Resp.WriteHeader(http.StatusOK) + + if !ctx.IsSigned { + // Return unauthorized status event + event := &eventsource.Event{ + Name: "close", + Data: "unauthorized", + } + _, _ = event.WriteTo(ctx) + ctx.Resp.Flush() + return + } + + // Listen to connection close and un-register messageChan + notify := ctx.Done() + ctx.Resp.Flush() + + shutdownCtx := graceful.GetManager().ShutdownContext() + + uid := ctx.User.ID + + messageChan := eventsource.GetManager().Register(uid) + + unregister := func() { + eventsource.GetManager().Unregister(uid, messageChan) + // ensure the messageChan is closed + for { + _, ok := <-messageChan + if !ok { + break + } + } + } + + if _, err := ctx.Resp.Write([]byte("\n")); err != nil { + log.Error("Unable to write to EventStream: %v", err) + unregister() + return + } + + timer := time.NewTicker(30 * time.Second) + + stopwatchTimer := time.NewTicker(setting.UI.Notification.MinTimeout) + +loop: + for { + select { + case <-timer.C: + event := &eventsource.Event{ + Name: "ping", + } + _, err := event.WriteTo(ctx.Resp) + if err != nil { + log.Error("Unable to write to EventStream for user %s: %v", ctx.User.Name, err) + go unregister() + break loop + } + ctx.Resp.Flush() + case <-notify: + go unregister() + break loop + case <-shutdownCtx.Done(): + go unregister() + break loop + case <-stopwatchTimer.C: + sws, err := models.GetUserStopwatches(ctx.User.ID, models.ListOptions{}) + if err != nil { + log.Error("Unable to GetUserStopwatches: %v", err) + continue + } + apiSWs, err := convert.ToStopWatches(sws) + if err != nil { + log.Error("Unable to APIFormat stopwatches: %v", err) + continue + } + json := jsoniter.ConfigCompatibleWithStandardLibrary + dataBs, err := json.Marshal(apiSWs) + if err != nil { + log.Error("Unable to marshal stopwatches: %v", err) + continue + } + _, err = (&eventsource.Event{ + Name: "stopwatches", + Data: string(dataBs), + }).WriteTo(ctx.Resp) + if err != nil { + log.Error("Unable to write to EventStream for user %s: %v", ctx.User.Name, err) + go unregister() + break loop + } + ctx.Resp.Flush() + case event, ok := <-messageChan: + if !ok { + break loop + } + + // Handle logout + if event.Name == "logout" { + if ctx.Session.ID() == event.Data { + _, _ = (&eventsource.Event{ + Name: "logout", + Data: "here", + }).WriteTo(ctx.Resp) + ctx.Resp.Flush() + go unregister() + user.HandleSignOut(ctx) + break loop + } + // Replace the event - we don't want to expose the session ID to the user + event = &eventsource.Event{ + Name: "logout", + Data: "elsewhere", + } + } + + _, err := event.WriteTo(ctx.Resp) + if err != nil { + log.Error("Unable to write to EventStream for user %s: %v", ctx.User.Name, err) + go unregister() + break loop + } + ctx.Resp.Flush() + } + } + timer.Stop() +} diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go new file mode 100644 index 0000000000..bf15b93cff --- /dev/null +++ b/routers/web/explore/code.go @@ -0,0 +1,139 @@ +// Copyright 2021 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 explore + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" +) + +const ( + // tplExploreCode explore code page template + tplExploreCode base.TplName = "explore/code" +) + +// Code render explore code page +func Code(ctx *context.Context) { + if !setting.Indexer.RepoIndexerEnabled { + ctx.Redirect(setting.AppSubURL+"/explore", 302) + return + } + + ctx.Data["UsersIsDisabled"] = setting.Service.Explore.DisableUsersPage + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreCode"] = true + + language := strings.TrimSpace(ctx.Query("l")) + keyword := strings.TrimSpace(ctx.Query("q")) + page := ctx.QueryInt("page") + if page <= 0 { + page = 1 + } + + queryType := strings.TrimSpace(ctx.Query("t")) + isMatch := queryType == "match" + + var ( + repoIDs []int64 + err error + isAdmin bool + ) + if ctx.User != nil { + isAdmin = ctx.User.IsAdmin + } + + // guest user or non-admin user + if ctx.User == nil || !isAdmin { + repoIDs, err = models.FindUserAccessibleRepoIDs(ctx.User) + if err != nil { + ctx.ServerError("SearchResults", err) + return + } + } + + var ( + total int + searchResults []*code_indexer.Result + searchResultLanguages []*code_indexer.SearchResultLanguages + ) + + // if non-admin login user, we need check UnitTypeCode at first + if ctx.User != nil && len(repoIDs) > 0 { + repoMaps, err := models.GetRepositoriesMapByIDs(repoIDs) + if err != nil { + ctx.ServerError("SearchResults", err) + return + } + + var rightRepoMap = make(map[int64]*models.Repository, len(repoMaps)) + repoIDs = make([]int64, 0, len(repoMaps)) + for id, repo := range repoMaps { + if repo.CheckUnitUser(ctx.User, models.UnitTypeCode) { + rightRepoMap[id] = repo + repoIDs = append(repoIDs, id) + } + } + + ctx.Data["RepoMaps"] = rightRepoMap + + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch) + if err != nil { + ctx.ServerError("SearchResults", err) + return + } + // if non-login user or isAdmin, no need to check UnitTypeCode + } else if (ctx.User == nil && len(repoIDs) > 0) || isAdmin { + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch) + if err != nil { + ctx.ServerError("SearchResults", err) + return + } + + var loadRepoIDs = make([]int64, 0, len(searchResults)) + for _, result := range searchResults { + var find bool + for _, id := range loadRepoIDs { + if id == result.RepoID { + find = true + break + } + } + if !find { + loadRepoIDs = append(loadRepoIDs, result.RepoID) + } + } + + repoMaps, err := models.GetRepositoriesMapByIDs(loadRepoIDs) + if err != nil { + ctx.ServerError("SearchResults", err) + return + } + + ctx.Data["RepoMaps"] = repoMaps + } + + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["queryType"] = queryType + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + ctx.Data["RequireHighlightJS"] = true + ctx.Data["PageIsViewCode"] = true + + pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "l", "Language") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplExploreCode) +} diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go new file mode 100644 index 0000000000..470e0eb853 --- /dev/null +++ b/routers/web/explore/org.go @@ -0,0 +1,39 @@ +// Copyright 2021 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 explore + +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" +) + +const ( + // tplExploreOrganizations explore organizations page template + tplExploreOrganizations base.TplName = "explore/organizations" +) + +// Organizations render explore organizations page +func Organizations(ctx *context.Context) { + ctx.Data["UsersIsDisabled"] = setting.Service.Explore.DisableUsersPage + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreOrganizations"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + visibleTypes := []structs.VisibleType{structs.VisibleTypePublic} + if ctx.User != nil { + visibleTypes = append(visibleTypes, structs.VisibleTypeLimited, structs.VisibleTypePrivate) + } + + RenderUserSearch(ctx, &models.SearchUserOptions{ + Actor: ctx.User, + Type: models.UserTypeOrganization, + ListOptions: models.ListOptions{PageSize: setting.UI.ExplorePagingNum}, + Visible: visibleTypes, + }, tplExploreOrganizations) +} diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go new file mode 100644 index 0000000000..e9efae5688 --- /dev/null +++ b/routers/web/explore/repo.go @@ -0,0 +1,131 @@ +// Copyright 2021 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 explore + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + // tplExploreRepos explore repositories page template + tplExploreRepos base.TplName = "explore/repos" +) + +// RepoSearchOptions when calling search repositories +type RepoSearchOptions struct { + OwnerID int64 + Private bool + Restricted bool + PageSize int + TplName base.TplName +} + +// RenderRepoSearch render repositories search page +func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { + page := ctx.QueryInt("page") + if page <= 0 { + page = 1 + } + + var ( + repos []*models.Repository + count int64 + err error + orderBy models.SearchOrderBy + ) + + ctx.Data["SortType"] = ctx.Query("sort") + switch ctx.Query("sort") { + case "newest": + orderBy = models.SearchOrderByNewest + case "oldest": + orderBy = models.SearchOrderByOldest + case "recentupdate": + orderBy = models.SearchOrderByRecentUpdated + case "leastupdate": + orderBy = models.SearchOrderByLeastUpdated + case "reversealphabetically": + orderBy = models.SearchOrderByAlphabeticallyReverse + case "alphabetically": + orderBy = models.SearchOrderByAlphabetically + case "reversesize": + orderBy = models.SearchOrderBySizeReverse + case "size": + orderBy = models.SearchOrderBySize + case "moststars": + orderBy = models.SearchOrderByStarsReverse + case "feweststars": + orderBy = models.SearchOrderByStars + case "mostforks": + orderBy = models.SearchOrderByForksReverse + case "fewestforks": + orderBy = models.SearchOrderByForks + default: + ctx.Data["SortType"] = "recentupdate" + orderBy = models.SearchOrderByRecentUpdated + } + + keyword := strings.Trim(ctx.Query("q"), " ") + topicOnly := ctx.QueryBool("topic") + ctx.Data["TopicOnly"] = topicOnly + + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + Page: page, + PageSize: opts.PageSize, + }, + Actor: ctx.User, + OrderBy: orderBy, + Private: opts.Private, + Keyword: keyword, + OwnerID: opts.OwnerID, + AllPublic: true, + AllLimited: true, + TopicOnly: topicOnly, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + ctx.Data["Keyword"] = keyword + ctx.Data["Total"] = count + ctx.Data["Repos"] = repos + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + pager := context.NewPagination(int(count), opts.PageSize, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "topic", "TopicOnly") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, opts.TplName) +} + +// Repos render explore repositories page +func Repos(ctx *context.Context) { + ctx.Data["UsersIsDisabled"] = setting.Service.Explore.DisableUsersPage + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreRepositories"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + var ownerID int64 + if ctx.User != nil && !ctx.User.IsAdmin { + ownerID = ctx.User.ID + } + + RenderRepoSearch(ctx, &RepoSearchOptions{ + PageSize: setting.UI.ExplorePagingNum, + OwnerID: ownerID, + Private: ctx.User != nil, + TplName: tplExploreRepos, + }) +} diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go new file mode 100644 index 0000000000..52f543fe66 --- /dev/null +++ b/routers/web/explore/user.go @@ -0,0 +1,107 @@ +// Copyright 2021 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 explore + +import ( + "bytes" + "net/http" + "strings" + + "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/modules/util" +) + +const ( + // tplExploreUsers explore users page template + tplExploreUsers base.TplName = "explore/users" +) + +var ( + nullByte = []byte{0x00} +) + +func isKeywordValid(keyword string) bool { + return !bytes.Contains([]byte(keyword), nullByte) +} + +// RenderUserSearch render user search page +func RenderUserSearch(ctx *context.Context, opts *models.SearchUserOptions, tplName base.TplName) { + opts.Page = ctx.QueryInt("page") + if opts.Page <= 1 { + opts.Page = 1 + } + + var ( + users []*models.User + count int64 + err error + orderBy models.SearchOrderBy + ) + + ctx.Data["SortType"] = ctx.Query("sort") + switch ctx.Query("sort") { + case "newest": + orderBy = models.SearchOrderByIDReverse + case "oldest": + orderBy = models.SearchOrderByID + case "recentupdate": + orderBy = models.SearchOrderByRecentUpdated + case "leastupdate": + orderBy = models.SearchOrderByLeastUpdated + case "reversealphabetically": + orderBy = models.SearchOrderByAlphabeticallyReverse + case "alphabetically": + orderBy = models.SearchOrderByAlphabetically + default: + ctx.Data["SortType"] = "alphabetically" + orderBy = models.SearchOrderByAlphabetically + } + + opts.Keyword = strings.Trim(ctx.Query("q"), " ") + opts.OrderBy = orderBy + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + users, count, err = models.SearchUsers(opts) + if err != nil { + ctx.ServerError("SearchUsers", err) + return + } + } + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Users"] = users + ctx.Data["UsersTwoFaStatus"] = models.UserList(users).GetTwoFaStatus() + ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplName) +} + +// Users render explore users page +func Users(ctx *context.Context) { + if setting.Service.Explore.DisableUsersPage { + ctx.Redirect(setting.AppSubURL + "/explore/repos") + return + } + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreUsers"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + RenderUserSearch(ctx, &models.SearchUserOptions{ + Actor: ctx.User, + Type: models.UserTypeIndividual, + ListOptions: models.ListOptions{PageSize: setting.UI.ExplorePagingNum}, + IsActive: util.OptionalBoolTrue, + Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + }, tplExploreUsers) +} diff --git a/routers/web/goget.go b/routers/web/goget.go new file mode 100644 index 0000000000..77934e7f55 --- /dev/null +++ b/routers/web/goget.go @@ -0,0 +1,86 @@ +// Copyright 2021 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 web + +import ( + "net/http" + "net/url" + "path" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "github.com/unknwon/com" +) + +func goGet(ctx *context.Context) { + if ctx.Req.Method != "GET" || ctx.Query("go-get") != "1" || len(ctx.Req.URL.Query()) > 1 { + return + } + + parts := strings.SplitN(ctx.Req.URL.EscapedPath(), "/", 4) + + if len(parts) < 3 { + return + } + + ownerName := parts[1] + repoName := parts[2] + + // Quick responses appropriate go-get meta with status 200 + // regardless of if user have access to the repository, + // or the repository does not exist at all. + // This is particular a workaround for "go get" command which does not respect + // .netrc file. + + trimmedRepoName := strings.TrimSuffix(repoName, ".git") + + if ownerName == "" || trimmedRepoName == "" { + _, _ = ctx.Write([]byte(`<!doctype html> +<html> + <body> + invalid import path + </body> +</html> +`)) + ctx.Status(400) + return + } + branchName := setting.Repository.DefaultBranch + + repo, err := models.GetRepositoryByOwnerAndName(ownerName, repoName) + if err == nil && len(repo.DefaultBranch) > 0 { + branchName = repo.DefaultBranch + } + prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName)) + + appURL, _ := url.Parse(setting.AppURL) + + insecure := "" + if appURL.Scheme == string(setting.HTTP) { + insecure = "--insecure " + } + ctx.Header().Set("Content-Type", "text/html") + ctx.Status(http.StatusOK) + _, _ = ctx.Write([]byte(com.Expand(`<!doctype html> +<html> + <head> + <meta name="go-import" content="{GoGetImport} git {CloneLink}"> + <meta name="go-source" content="{GoGetImport} _ {GoDocDirectory} {GoDocFile}"> + </head> + <body> + go get {Insecure}{GoGetImport} + </body> +</html> +`, map[string]string{ + "GoGetImport": context.ComposeGoGetImport(ownerName, trimmedRepoName), + "CloneLink": models.ComposeHTTPSCloneURL(ownerName, repoName), + "GoDocDirectory": prefix + "{/dir}", + "GoDocFile": prefix + "{/dir}/{file}#L{line}", + "Insecure": insecure, + }))) +} diff --git a/routers/web/home.go b/routers/web/home.go new file mode 100644 index 0000000000..f50197691f --- /dev/null +++ b/routers/web/home.go @@ -0,0 +1,65 @@ +// 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 web + +import ( + "net/http" + + "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/web/middleware" + "code.gitea.io/gitea/routers/web/user" +) + +const ( + // tplHome home page template + tplHome base.TplName = "home" +) + +// Home render home page +func Home(ctx *context.Context) { + if ctx.IsSigned { + if !ctx.User.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, user.TplActivate) + } else if !ctx.User.IsActive || ctx.User.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.User.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if ctx.User.MustChangePassword { + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") + } else { + user.Dashboard(ctx) + } + return + // Check non-logged users landing page. + } else if setting.LandingPageURL != setting.LandingPageHome { + ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL)) + return + } + + // Check auto-login. + uname := ctx.GetCookie(setting.CookieUserName) + if len(uname) != 0 { + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + + ctx.Data["PageIsHome"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.HTML(http.StatusOK, tplHome) +} + +// NotFound render 404 page +func NotFound(ctx *context.Context) { + ctx.Data["Title"] = "Page Not Found" + ctx.NotFound("home.NotFound", nil) +} diff --git a/routers/web/metrics.go b/routers/web/metrics.go new file mode 100644 index 0000000000..37558ee337 --- /dev/null +++ b/routers/web/metrics.go @@ -0,0 +1,34 @@ +// 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 web + +import ( + "crypto/subtle" + "net/http" + + "code.gitea.io/gitea/modules/setting" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Metrics validate auth token and render prometheus metrics +func Metrics(resp http.ResponseWriter, req *http.Request) { + if setting.Metrics.Token == "" { + promhttp.Handler().ServeHTTP(resp, req) + return + } + header := req.Header.Get("Authorization") + if header == "" { + http.Error(resp, "", 401) + return + } + got := []byte(header) + want := []byte("Bearer " + setting.Metrics.Token) + if subtle.ConstantTimeCompare(got, want) != 1 { + http.Error(resp, "", 401) + return + } + promhttp.Handler().ServeHTTP(resp, req) +} diff --git a/routers/web/org/home.go b/routers/web/org/home.go new file mode 100644 index 0000000000..d84ae870ab --- /dev/null +++ b/routers/web/org/home.go @@ -0,0 +1,151 @@ +// 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 org + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplOrgHome base.TplName = "org/home" +) + +// Home show organization home page +func Home(ctx *context.Context) { + ctx.SetParams(":org", ctx.Params(":username")) + context.HandleOrgAssignment(ctx) + if ctx.Written() { + return + } + + org := ctx.Org.Organization + + if !models.HasOrgVisible(org, ctx.User) { + ctx.NotFound("HasOrgVisible", nil) + return + } + + ctx.Data["PageIsUserProfile"] = true + ctx.Data["Title"] = org.DisplayName() + if len(org.Description) != 0 { + desc, err := markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: map[string]string{"mode": "document"}, + }, org.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + ctx.Data["RenderedDescription"] = desc + } + + var orderBy models.SearchOrderBy + ctx.Data["SortType"] = ctx.Query("sort") + switch ctx.Query("sort") { + case "newest": + orderBy = models.SearchOrderByNewest + case "oldest": + orderBy = models.SearchOrderByOldest + case "recentupdate": + orderBy = models.SearchOrderByRecentUpdated + case "leastupdate": + orderBy = models.SearchOrderByLeastUpdated + case "reversealphabetically": + orderBy = models.SearchOrderByAlphabeticallyReverse + case "alphabetically": + orderBy = models.SearchOrderByAlphabetically + case "moststars": + orderBy = models.SearchOrderByStarsReverse + case "feweststars": + orderBy = models.SearchOrderByStars + case "mostforks": + orderBy = models.SearchOrderByForksReverse + case "fewestforks": + orderBy = models.SearchOrderByForks + default: + ctx.Data["SortType"] = "recentupdate" + orderBy = models.SearchOrderByRecentUpdated + } + + keyword := strings.Trim(ctx.Query("q"), " ") + ctx.Data["Keyword"] = keyword + + page := ctx.QueryInt("page") + if page <= 0 { + page = 1 + } + + var ( + repos []*models.Repository + count int64 + err error + ) + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Keyword: keyword, + OwnerID: org.ID, + OrderBy: orderBy, + Private: ctx.IsSigned, + Actor: ctx.User, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + var opts = models.FindOrgMembersOpts{ + OrgID: org.ID, + PublicOnly: true, + ListOptions: models.ListOptions{Page: 1, PageSize: 25}, + } + + if ctx.User != nil { + isMember, err := org.IsOrgMember(ctx.User.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember") + return + } + opts.PublicOnly = !isMember && !ctx.User.IsAdmin + } + + members, _, err := models.FindOrgMembers(&opts) + if err != nil { + ctx.ServerError("FindOrgMembers", err) + return + } + + membersCount, err := models.CountOrgMembers(opts) + if err != nil { + ctx.ServerError("CountOrgMembers", err) + return + } + + ctx.Data["Owner"] = org + ctx.Data["Repos"] = repos + ctx.Data["Total"] = count + ctx.Data["MembersTotal"] = membersCount + ctx.Data["Members"] = members + ctx.Data["Teams"] = org.Teams + + ctx.Data["DisabledMirrors"] = setting.Repository.DisableMirrors + + pager := context.NewPagination(int(count), setting.UI.User.RepoPagingNum, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplOrgHome) +} diff --git a/routers/web/org/members.go b/routers/web/org/members.go new file mode 100644 index 0000000000..934529d7d7 --- /dev/null +++ b/routers/web/org/members.go @@ -0,0 +1,128 @@ +// 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 org + +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/log" + "code.gitea.io/gitea/modules/setting" +) + +const ( + // tplMembers template for organization members page + tplMembers base.TplName = "org/member/members" +) + +// Members render organization users page +func Members(ctx *context.Context) { + org := ctx.Org.Organization + ctx.Data["Title"] = org.FullName + ctx.Data["PageIsOrgMembers"] = true + + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + var opts = models.FindOrgMembersOpts{ + OrgID: org.ID, + PublicOnly: true, + } + + if ctx.User != nil { + isMember, err := ctx.Org.Organization.IsOrgMember(ctx.User.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember") + return + } + opts.PublicOnly = !isMember && !ctx.User.IsAdmin + } + + total, err := models.CountOrgMembers(opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CountOrgMembers") + return + } + + pager := context.NewPagination(int(total), setting.UI.MembersPagingNum, page, 5) + opts.ListOptions.Page = page + opts.ListOptions.PageSize = setting.UI.MembersPagingNum + members, membersIsPublic, err := models.FindOrgMembers(&opts) + if err != nil { + ctx.ServerError("GetMembers", err) + return + } + ctx.Data["Page"] = pager + ctx.Data["Members"] = members + ctx.Data["MembersIsPublicMember"] = membersIsPublic + ctx.Data["MembersIsUserOrgOwner"] = members.IsUserOrgOwner(org.ID) + ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus() + + ctx.HTML(http.StatusOK, tplMembers) +} + +// MembersAction response for operation to a member of organization +func MembersAction(ctx *context.Context) { + uid := ctx.QueryInt64("uid") + if uid == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/members") + return + } + + org := ctx.Org.Organization + var err error + switch ctx.Params(":action") { + case "private": + if ctx.User.ID != uid && !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = models.ChangeOrgUserStatus(org.ID, uid, false) + case "public": + if ctx.User.ID != uid && !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = models.ChangeOrgUserStatus(org.ID, uid, true) + case "remove": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = org.RemoveMember(uid) + if models.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + ctx.Redirect(ctx.Org.OrgLink + "/members") + return + } + case "leave": + err = org.RemoveMember(ctx.User.ID) + if models.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + ctx.Redirect(ctx.Org.OrgLink + "/members") + return + } + } + + if err != nil { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": false, + "err": err.Error(), + }) + return + } + + if ctx.Params(":action") != "leave" { + ctx.Redirect(ctx.Org.OrgLink + "/members") + } else { + ctx.Redirect(setting.AppSubURL + "/") + } +} diff --git a/routers/web/org/org.go b/routers/web/org/org.go new file mode 100644 index 0000000000..beba3daca4 --- /dev/null +++ b/routers/web/org/org.go @@ -0,0 +1,79 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 org + +import ( + "errors" + "net/http" + + "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/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + // tplCreateOrg template path for create organization + tplCreateOrg base.TplName = "org/create" +) + +// Create render the page for create organization +func Create(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_org") + ctx.Data["DefaultOrgVisibilityMode"] = setting.Service.DefaultOrgVisibilityMode + if !ctx.User.CanCreateOrganization() { + ctx.ServerError("Not allowed", errors.New(ctx.Tr("org.form.create_org_not_allowed"))) + return + } + ctx.HTML(http.StatusOK, tplCreateOrg) +} + +// CreatePost response for create organization +func CreatePost(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.CreateOrgForm) + ctx.Data["Title"] = ctx.Tr("new_org") + + if !ctx.User.CanCreateOrganization() { + ctx.ServerError("Not allowed", errors.New(ctx.Tr("org.form.create_org_not_allowed"))) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplCreateOrg) + return + } + + org := &models.User{ + Name: form.OrgName, + IsActive: true, + Type: models.UserTypeOrganization, + Visibility: form.Visibility, + RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess, + } + + if err := models.CreateOrganization(org, ctx.User); err != nil { + ctx.Data["Err_OrgName"] = true + switch { + case models.IsErrUserAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.org_name_been_taken"), tplCreateOrg, &form) + case models.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("org.form.name_reserved", err.(models.ErrNameReserved).Name), tplCreateOrg, &form) + case models.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("org.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tplCreateOrg, &form) + case models.IsErrUserNotAllowedCreateOrg(err): + ctx.RenderWithErr(ctx.Tr("org.form.create_org_not_allowed"), tplCreateOrg, &form) + default: + ctx.ServerError("CreateOrganization", err) + } + return + } + log.Trace("Organization created: %s", org.Name) + + ctx.Redirect(org.DashboardLink()) +} diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go new file mode 100644 index 0000000000..26e232bcc9 --- /dev/null +++ b/routers/web/org/org_labels.go @@ -0,0 +1,112 @@ +// Copyright 2020 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 org + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +// RetrieveLabels find all the labels of an organization +func RetrieveLabels(ctx *context.Context) { + labels, err := models.GetLabelsByOrgID(ctx.Org.Organization.ID, ctx.Query("sort"), models.ListOptions{}) + if err != nil { + ctx.ServerError("RetrieveLabels.GetLabels", err) + return + } + for _, l := range labels { + l.CalOpenIssues() + } + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SortType"] = ctx.Query("sort") +} + +// NewLabel create new label for organization +func NewLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsLabels"] = true + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + return + } + + l := &models.Label{ + OrgID: ctx.Org.Organization.ID, + Name: form.Title, + Description: form.Description, + Color: form.Color, + } + if err := models.NewLabel(l); err != nil { + ctx.ServerError("NewLabel", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} + +// UpdateLabel update a label's name and color +func UpdateLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + l, err := models.GetLabelInOrgByID(ctx.Org.Organization.ID, form.ID) + if err != nil { + switch { + case models.IsErrOrgLabelNotExist(err): + ctx.Error(http.StatusNotFound) + default: + ctx.ServerError("UpdateLabel", err) + } + return + } + + l.Name = form.Title + l.Description = form.Description + l.Color = form.Color + if err := models.UpdateLabel(l); err != nil { + ctx.ServerError("UpdateLabel", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} + +// DeleteLabel delete a label +func DeleteLabel(ctx *context.Context) { + if err := models.DeleteLabel(ctx.Org.Organization.ID, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteLabel: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Org.OrgLink + "/settings/labels", + }) +} + +// InitializeLabels init labels for an organization +func InitializeLabels(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.InitializeLabelsForm) + if ctx.HasError() { + ctx.Redirect(ctx.Repo.RepoLink + "/labels") + return + } + + if err := models.InitializeLabels(models.DefaultDBContext(), ctx.Org.Organization.ID, form.TemplateName, true); err != nil { + if models.IsErrIssueLabelTemplateLoad(err) { + originalErr := err.(models.ErrIssueLabelTemplateLoad).OriginalError + ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + return + } + ctx.ServerError("InitializeLabels", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go new file mode 100644 index 0000000000..aed90c66f7 --- /dev/null +++ b/routers/web/org/setting.go @@ -0,0 +1,219 @@ +// 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 org + +import ( + "net/http" + "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/setting" + "code.gitea.io/gitea/modules/web" + userSetting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/forms" +) + +const ( + // tplSettingsOptions template path for render settings + tplSettingsOptions base.TplName = "org/settings/options" + // tplSettingsDelete template path for render delete repository + tplSettingsDelete base.TplName = "org/settings/delete" + // tplSettingsHooks template path for render hook settings + tplSettingsHooks base.TplName = "org/settings/hooks" + // tplSettingsLabels template path for render labels settings + tplSettingsLabels base.TplName = "org/settings/labels" +) + +// Settings render the main settings page +func Settings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility + ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess + ctx.HTML(http.StatusOK, tplSettingsOptions) +} + +// SettingsPost response for settings change submited +func SettingsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateOrgSettingForm) + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsOptions) + return + } + + org := ctx.Org.Organization + nameChanged := org.Name != form.Name + + // Check if organization name has been changed. + if org.LowerName != strings.ToLower(form.Name) { + isExist, err := models.IsUserExist(org.ID, form.Name) + if err != nil { + ctx.ServerError("IsUserExist", err) + return + } else if isExist { + ctx.Data["OrgName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) + return + } else if err = models.ChangeUserName(org, form.Name); err != nil { + if err == models.ErrUserNameIllegal { + ctx.Data["OrgName"] = true + ctx.RenderWithErr(ctx.Tr("form.illegal_username"), tplSettingsOptions, &form) + } else { + ctx.ServerError("ChangeUserName", err) + } + return + } + // reset ctx.org.OrgLink with new name + ctx.Org.OrgLink = setting.AppSubURL + "/org/" + form.Name + log.Trace("Organization name changed: %s -> %s", org.Name, form.Name) + nameChanged = false + } + + // In case it's just a case change. + org.Name = form.Name + org.LowerName = strings.ToLower(form.Name) + + if ctx.User.IsAdmin { + org.MaxRepoCreation = form.MaxRepoCreation + } + + org.FullName = form.FullName + org.Description = form.Description + org.Website = form.Website + org.Location = form.Location + org.RepoAdminChangeTeamAccess = form.RepoAdminChangeTeamAccess + + visibilityChanged := form.Visibility != org.Visibility + org.Visibility = form.Visibility + + if err := models.UpdateUser(org); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + // update forks visibility + if visibilityChanged { + if err := org.GetRepositories(models.ListOptions{Page: 1, PageSize: org.NumRepos}); err != nil { + ctx.ServerError("GetRepositories", err) + return + } + for _, repo := range org.Repos { + repo.OwnerName = org.Name + if err := models.UpdateRepository(repo, true); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + } + } else if nameChanged { + if err := models.UpdateRepositoryOwnerNames(org.ID, org.Name); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + } + + log.Trace("Organization setting updated: %s", org.Name) + ctx.Flash.Success(ctx.Tr("org.settings.update_setting_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsAvatar response for change avatar on settings page +func SettingsAvatar(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AvatarForm) + form.Source = forms.AvatarLocal + if err := userSetting.UpdateAvatarSetting(ctx, form, ctx.Org.Organization); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("org.settings.update_avatar_success")) + } + + ctx.Redirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsDeleteAvatar response for delete avatar on setings page +func SettingsDeleteAvatar(ctx *context.Context) { + if err := ctx.Org.Organization.DeleteAvatar(); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.Redirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsDelete response for deleting an organization +func SettingsDelete(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsSettingsDelete"] = true + + org := ctx.Org.Organization + if ctx.Req.Method == "POST" { + if org.Name != ctx.Query("org_name") { + ctx.Data["Err_OrgName"] = true + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_org_name"), tplSettingsDelete, nil) + return + } + + if err := models.DeleteOrganization(org); err != nil { + if models.IsErrUserOwnRepos(err) { + ctx.Flash.Error(ctx.Tr("form.org_still_own_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") + } else { + ctx.ServerError("DeleteOrganization", err) + } + } else { + log.Trace("Organization deleted: %s", org.Name) + ctx.Redirect(setting.AppSubURL + "/") + } + return + } + + ctx.HTML(http.StatusOK, tplSettingsDelete) +} + +// Webhooks render webhook list page +func Webhooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = ctx.Org.OrgLink + "/settings/hooks" + ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks" + ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc") + + ws, err := models.GetWebhooksByOrgID(ctx.Org.Organization.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("GetWebhooksByOrgId", err) + return + } + + ctx.Data["Webhooks"] = ws + ctx.HTML(http.StatusOK, tplSettingsHooks) +} + +// DeleteWebhook response for delete webhook +func DeleteWebhook(ctx *context.Context) { + if err := models.DeleteWebhookByOrgID(ctx.Org.Organization.ID, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByOrgID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Org.OrgLink + "/settings/hooks", + }) +} + +// Labels render organization labels page +func Labels(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsOrgSettingsLabels"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["LabelTemplates"] = models.LabelTemplates + ctx.HTML(http.StatusOK, tplSettingsLabels) +} diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go new file mode 100644 index 0000000000..e612cd767c --- /dev/null +++ b/routers/web/org/teams.go @@ -0,0 +1,357 @@ +// 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 org + +import ( + "net/http" + "path" + "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/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/forms" +) + +const ( + // tplTeams template path for teams list page + tplTeams base.TplName = "org/team/teams" + // tplTeamNew template path for create new team page + tplTeamNew base.TplName = "org/team/new" + // tplTeamMembers template path for showing team members page + tplTeamMembers base.TplName = "org/team/members" + // tplTeamRepositories template path for showing team repositories page + tplTeamRepositories base.TplName = "org/team/repositories" +) + +// Teams render teams list page +func Teams(ctx *context.Context) { + org := ctx.Org.Organization + ctx.Data["Title"] = org.FullName + ctx.Data["PageIsOrgTeams"] = true + + for _, t := range org.Teams { + if err := t.GetMembers(&models.SearchMembersOptions{}); err != nil { + ctx.ServerError("GetMembers", err) + return + } + } + ctx.Data["Teams"] = org.Teams + + ctx.HTML(http.StatusOK, tplTeams) +} + +// TeamsAction response for join, leave, remove, add operations to team +func TeamsAction(ctx *context.Context) { + uid := ctx.QueryInt64("uid") + if uid == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/teams") + return + } + + page := ctx.Query("page") + var err error + switch ctx.Params(":action") { + case "join": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = ctx.Org.Team.AddMember(ctx.User.ID) + case "leave": + err = ctx.Org.Team.RemoveMember(ctx.User.ID) + case "remove": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = ctx.Org.Team.RemoveMember(uid) + page = "team" + case "add": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.Query("uname"))) + var u *models.User + u, err = models.GetUserByName(uname) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + ctx.Org.Team.LowerName) + } else { + ctx.ServerError(" GetUserByName", err) + } + return + } + + if u.IsOrganization() { + ctx.Flash.Error(ctx.Tr("form.cannot_add_org_to_team")) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + ctx.Org.Team.LowerName) + return + } + + if ctx.Org.Team.IsMember(u.ID) { + ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) + } else { + err = ctx.Org.Team.AddMember(u.ID) + } + + page = "team" + } + + if err != nil { + if models.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + } else { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": false, + "err": err.Error(), + }) + return + } + } + + switch page { + case "team": + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + ctx.Org.Team.LowerName) + case "home": + ctx.Redirect(ctx.Org.Organization.HomeLink()) + default: + ctx.Redirect(ctx.Org.OrgLink + "/teams") + } +} + +// TeamsRepoAction operate team's repository +func TeamsRepoAction(ctx *context.Context) { + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + + var err error + action := ctx.Params(":action") + switch action { + case "add": + repoName := path.Base(ctx.Query("repo_name")) + var repo *models.Repository + repo, err = models.GetRepositoryByName(ctx.Org.Organization.ID, repoName) + if err != nil { + if models.IsErrRepoNotExist(err) { + ctx.Flash.Error(ctx.Tr("org.teams.add_nonexistent_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + ctx.Org.Team.LowerName + "/repositories") + return + } + ctx.ServerError("GetRepositoryByName", err) + return + } + err = ctx.Org.Team.AddRepository(repo) + case "remove": + err = ctx.Org.Team.RemoveRepository(ctx.QueryInt64("repoid")) + case "addall": + err = ctx.Org.Team.AddAllRepositories() + case "removeall": + err = ctx.Org.Team.RemoveAllRepositories() + } + + if err != nil { + log.Error("Action(%s): '%s' %v", ctx.Params(":action"), ctx.Org.Team.Name, err) + ctx.ServerError("TeamsRepoAction", err) + return + } + + if action == "addall" || action == "removeall" { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Org.OrgLink + "/teams/" + ctx.Org.Team.LowerName + "/repositories", + }) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + ctx.Org.Team.LowerName + "/repositories") +} + +// NewTeam render create new team page +func NewTeam(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamsNew"] = true + ctx.Data["Team"] = &models.Team{} + ctx.Data["Units"] = models.Units + ctx.HTML(http.StatusOK, tplTeamNew) +} + +// NewTeamPost response for create new team +func NewTeamPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateTeamForm) + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamsNew"] = true + ctx.Data["Units"] = models.Units + var includesAllRepositories = form.RepoAccess == "all" + + t := &models.Team{ + OrgID: ctx.Org.Organization.ID, + Name: form.TeamName, + Description: form.Description, + Authorize: models.ParseAccessMode(form.Permission), + IncludesAllRepositories: includesAllRepositories, + CanCreateOrgRepo: form.CanCreateOrgRepo, + } + + if t.Authorize < models.AccessModeOwner { + var units = make([]*models.TeamUnit, 0, len(form.Units)) + for _, tp := range form.Units { + units = append(units, &models.TeamUnit{ + OrgID: ctx.Org.Organization.ID, + Type: tp, + }) + } + t.Units = units + } + + ctx.Data["Team"] = t + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTeamNew) + return + } + + if t.Authorize < models.AccessModeAdmin && len(form.Units) == 0 { + ctx.RenderWithErr(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form) + return + } + + if err := models.NewTeam(t); err != nil { + ctx.Data["Err_TeamName"] = true + switch { + case models.IsErrTeamAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form) + default: + ctx.ServerError("NewTeam", err) + } + return + } + log.Trace("Team created: %s/%s", ctx.Org.Organization.Name, t.Name) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + t.LowerName) +} + +// TeamMembers render team members page +func TeamMembers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Team.Name + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamMembers"] = true + if err := ctx.Org.Team.GetMembers(&models.SearchMembersOptions{}); err != nil { + ctx.ServerError("GetMembers", err) + return + } + ctx.HTML(http.StatusOK, tplTeamMembers) +} + +// TeamRepositories show the repositories of team +func TeamRepositories(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Team.Name + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamRepos"] = true + if err := ctx.Org.Team.GetRepositories(&models.SearchTeamOptions{}); err != nil { + ctx.ServerError("GetRepositories", err) + return + } + ctx.HTML(http.StatusOK, tplTeamRepositories) +} + +// EditTeam render team edit page +func EditTeam(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["team_name"] = ctx.Org.Team.Name + ctx.Data["desc"] = ctx.Org.Team.Description + ctx.Data["Units"] = models.Units + ctx.HTML(http.StatusOK, tplTeamNew) +} + +// EditTeamPost response for modify team information +func EditTeamPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateTeamForm) + t := ctx.Org.Team + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["Team"] = t + ctx.Data["Units"] = models.Units + + isAuthChanged := false + isIncludeAllChanged := false + var includesAllRepositories = form.RepoAccess == "all" + if !t.IsOwnerTeam() { + // Validate permission level. + auth := models.ParseAccessMode(form.Permission) + + t.Name = form.TeamName + if t.Authorize != auth { + isAuthChanged = true + t.Authorize = auth + } + + if t.IncludesAllRepositories != includesAllRepositories { + isIncludeAllChanged = true + t.IncludesAllRepositories = includesAllRepositories + } + } + t.Description = form.Description + if t.Authorize < models.AccessModeOwner { + var units = make([]models.TeamUnit, 0, len(form.Units)) + for _, tp := range form.Units { + units = append(units, models.TeamUnit{ + OrgID: t.OrgID, + TeamID: t.ID, + Type: tp, + }) + } + err := models.UpdateTeamUnits(t, units) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err.Error()) + return + } + } + t.CanCreateOrgRepo = form.CanCreateOrgRepo + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTeamNew) + return + } + + if t.Authorize < models.AccessModeAdmin && len(form.Units) == 0 { + ctx.RenderWithErr(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form) + return + } + + if err := models.UpdateTeam(t, isAuthChanged, isIncludeAllChanged); err != nil { + ctx.Data["Err_TeamName"] = true + switch { + case models.IsErrTeamAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form) + default: + ctx.ServerError("UpdateTeam", err) + } + return + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + t.LowerName) +} + +// DeleteTeam response for the delete team request +func DeleteTeam(ctx *context.Context) { + if err := models.DeleteTeam(ctx.Org.Team); err != nil { + ctx.Flash.Error("DeleteTeam: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Org.OrgLink + "/teams", + }) +} diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go new file mode 100644 index 0000000000..dcb7bf57cd --- /dev/null +++ b/routers/web/repo/activity.go @@ -0,0 +1,103 @@ +// 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 repo + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" +) + +const ( + tplActivity base.TplName = "repo/activity" +) + +// Activity render the page to show repository latest changes +func Activity(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity") + ctx.Data["PageIsActivity"] = true + + ctx.Data["Period"] = ctx.Params("period") + + timeUntil := time.Now() + var timeFrom time.Time + + switch ctx.Data["Period"] { + case "daily": + timeFrom = timeUntil.Add(-time.Hour * 24) + case "halfweekly": + timeFrom = timeUntil.Add(-time.Hour * 72) + case "weekly": + timeFrom = timeUntil.Add(-time.Hour * 168) + case "monthly": + timeFrom = timeUntil.AddDate(0, -1, 0) + case "quarterly": + timeFrom = timeUntil.AddDate(0, -3, 0) + case "semiyearly": + timeFrom = timeUntil.AddDate(0, -6, 0) + case "yearly": + timeFrom = timeUntil.AddDate(-1, 0, 0) + default: + ctx.Data["Period"] = "weekly" + timeFrom = timeUntil.Add(-time.Hour * 168) + } + ctx.Data["DateFrom"] = timeFrom.Format("January 2, 2006") + ctx.Data["DateUntil"] = timeUntil.Format("January 2, 2006") + ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string)) + + var err error + if ctx.Data["Activity"], err = models.GetActivityStats(ctx.Repo.Repository, timeFrom, + ctx.Repo.CanRead(models.UnitTypeReleases), + ctx.Repo.CanRead(models.UnitTypeIssues), + ctx.Repo.CanRead(models.UnitTypePullRequests), + ctx.Repo.CanRead(models.UnitTypeCode)); err != nil { + ctx.ServerError("GetActivityStats", err) + return + } + + if ctx.Data["ActivityTopAuthors"], err = models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10); err != nil { + ctx.ServerError("GetActivityStatsTopAuthors", err) + return + } + + ctx.HTML(http.StatusOK, tplActivity) +} + +// ActivityAuthors renders JSON with top commit authors for given time period over all branches +func ActivityAuthors(ctx *context.Context) { + timeUntil := time.Now() + var timeFrom time.Time + + switch ctx.Params("period") { + case "daily": + timeFrom = timeUntil.Add(-time.Hour * 24) + case "halfweekly": + timeFrom = timeUntil.Add(-time.Hour * 72) + case "weekly": + timeFrom = timeUntil.Add(-time.Hour * 168) + case "monthly": + timeFrom = timeUntil.AddDate(0, -1, 0) + case "quarterly": + timeFrom = timeUntil.AddDate(0, -3, 0) + case "semiyearly": + timeFrom = timeUntil.AddDate(0, -6, 0) + case "yearly": + timeFrom = timeUntil.AddDate(-1, 0, 0) + default: + timeFrom = timeUntil.Add(-time.Hour * 168) + } + + var err error + authors, err := models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10) + if err != nil { + ctx.ServerError("GetActivityStatsTopAuthors", err) + return + } + + ctx.JSON(http.StatusOK, authors) +} diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go new file mode 100644 index 0000000000..5becbea271 --- /dev/null +++ b/routers/web/repo/attachment.go @@ -0,0 +1,160 @@ +// 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 repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/upload" + "code.gitea.io/gitea/routers/common" +) + +// UploadIssueAttachment response for Issue/PR attachments +func UploadIssueAttachment(ctx *context.Context) { + uploadAttachment(ctx, setting.Attachment.AllowedTypes) +} + +// UploadReleaseAttachment response for uploading release attachments +func UploadReleaseAttachment(ctx *context.Context) { + uploadAttachment(ctx, setting.Repository.Release.AllowedTypes) +} + +// UploadAttachment response for uploading attachments +func uploadAttachment(ctx *context.Context, allowedTypes string) { + if !setting.Attachment.Enabled { + ctx.Error(http.StatusNotFound, "attachment is not enabled") + return + } + + file, header, err := ctx.Req.FormFile("file") + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + return + } + defer file.Close() + + buf := make([]byte, 1024) + n, _ := file.Read(buf) + if n > 0 { + buf = buf[:n] + } + + err = upload.Verify(buf, header.Filename, allowedTypes) + if err != nil { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + + attach, err := models.NewAttachment(&models.Attachment{ + UploaderID: ctx.User.ID, + Name: header.Filename, + }, buf, file) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("NewAttachment: %v", err)) + return + } + + log.Trace("New attachment uploaded: %s", attach.UUID) + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": attach.UUID, + }) +} + +// DeleteAttachment response for deleting issue's attachment +func DeleteAttachment(ctx *context.Context) { + file := ctx.Query("file") + attach, err := models.GetAttachmentByUUID(file) + if err != nil { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + if !ctx.IsSigned || (ctx.User.ID != attach.UploaderID) { + ctx.Error(http.StatusForbidden) + return + } + err = models.DeleteAttachment(attach, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteAttachment: %v", err)) + return + } + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": attach.UUID, + }) +} + +// GetAttachment serve attachements +func GetAttachment(ctx *context.Context) { + attach, err := models.GetAttachmentByUUID(ctx.Params(":uuid")) + if err != nil { + if models.IsErrAttachmentNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.ServerError("GetAttachmentByUUID", err) + } + return + } + + repository, unitType, err := attach.LinkedRepository() + if err != nil { + ctx.ServerError("LinkedRepository", err) + return + } + + if repository == nil { //If not linked + if !(ctx.IsSigned && attach.UploaderID == ctx.User.ID) { //We block if not the uploader + ctx.Error(http.StatusNotFound) + return + } + } else { //If we have the repository we check access + perm, err := models.GetUserRepoPermission(repository, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) + return + } + if !perm.CanRead(unitType) { + ctx.Error(http.StatusNotFound) + return + } + } + + if err := attach.IncreaseDownloadCount(); err != nil { + ctx.ServerError("IncreaseDownloadCount", err) + return + } + + if setting.Attachment.ServeDirect { + //If we have a signed url (S3, object storage), redirect to this directly. + u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name) + + if u != nil && err == nil { + ctx.Redirect(u.String()) + return + } + } + + if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`) { + return + } + + //If we have matched and access to release or issue + fr, err := storage.Attachments.Open(attach.RelativePath()) + if err != nil { + ctx.ServerError("Open", err) + return + } + defer fr.Close() + + if err = common.ServeData(ctx, attach.Name, attach.Size, fr); err != nil { + ctx.ServerError("ServeData", err) + return + } +} diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go new file mode 100644 index 0000000000..1a3e1dcb9c --- /dev/null +++ b/routers/web/repo/blame.go @@ -0,0 +1,251 @@ +// 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 repo + +import ( + "bytes" + "container/list" + "fmt" + "html" + gotemplate "html/template" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" +) + +const ( + tplBlame base.TplName = "repo/home" +) + +// RefBlame render blame page +func RefBlame(ctx *context.Context) { + fileName := ctx.Repo.TreePath + if len(fileName) == 0 { + ctx.NotFound("Blame FileName", nil) + return + } + + userName := ctx.Repo.Owner.Name + repoName := ctx.Repo.Repository.Name + commitID := ctx.Repo.CommitID + + commit, err := ctx.Repo.GitRepo.GetCommit(commitID) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Repo.GitRepo.GetCommit", err) + } else { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + } + return + } + if len(commitID) != 40 { + commitID = commit.ID.String() + } + + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + + if len(ctx.Repo.TreePath) > 0 { + treeLink += "/" + ctx.Repo.TreePath + } + + var treeNames []string + paths := make([]string, 0, 5) + if len(ctx.Repo.TreePath) > 0 { + treeNames = strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + + ctx.Data["HasParentPath"] = true + if len(paths)-2 >= 0 { + ctx.Data["ParentPath"] = "/" + paths[len(paths)-1] + } + } + + // Show latest commit info of repository in table header, + // or of directory if not in root directory. + latestCommit := ctx.Repo.Commit + if len(ctx.Repo.TreePath) > 0 { + latestCommit, err = ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + } + ctx.Data["LatestCommit"] = latestCommit + ctx.Data["LatestCommitVerification"] = models.ParseCommitWithSignature(latestCommit) + ctx.Data["LatestCommitUser"] = models.ValidateCommitWithEmail(latestCommit) + + statuses, err := models.GetLatestCommitStatus(ctx.Repo.Repository.ID, ctx.Repo.Commit.ID.String(), models.ListOptions{}) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + ctx.NotFoundOrServerError("Repo.Commit.GetTreeEntryByPath", git.IsErrNotExist, err) + return + } + + blob := entry.Blob() + + ctx.Data["LatestCommitStatus"] = models.CalcCommitStatus(statuses) + ctx.Data["LatestCommitStatuses"] = statuses + + ctx.Data["Paths"] = paths + ctx.Data["TreeLink"] = treeLink + ctx.Data["TreeNames"] = treeNames + ctx.Data["BranchLink"] = branchLink + + ctx.Data["RawFileLink"] = rawLink + "/" + ctx.Repo.TreePath + ctx.Data["PageIsViewCode"] = true + + ctx.Data["IsBlame"] = true + + ctx.Data["FileSize"] = blob.Size() + ctx.Data["FileName"] = blob.Name() + + ctx.Data["NumLines"], err = blob.GetBlobLineCount() + if err != nil { + ctx.NotFound("GetBlobLineCount", err) + return + } + + blameReader, err := git.CreateBlameReader(ctx, models.RepoPath(userName, repoName), commitID, fileName) + if err != nil { + ctx.NotFound("CreateBlameReader", err) + return + } + defer blameReader.Close() + + blameParts := make([]git.BlamePart, 0) + + for { + blamePart, err := blameReader.NextPart() + if err != nil { + ctx.NotFound("NextPart", err) + return + } + if blamePart == nil { + break + } + blameParts = append(blameParts, *blamePart) + } + + commitNames := make(map[string]models.UserCommit) + commits := list.New() + + for _, part := range blameParts { + sha := part.Sha + if _, ok := commitNames[sha]; ok { + continue + } + + commit, err := ctx.Repo.GitRepo.GetCommit(sha) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Repo.GitRepo.GetCommit", err) + } else { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + } + return + } + + commits.PushBack(commit) + + commitNames[commit.ID.String()] = models.UserCommit{} + } + + commits = models.ValidateCommitsWithEmails(commits) + + for e := commits.Front(); e != nil; e = e.Next() { + c := e.Value.(models.UserCommit) + + commitNames[c.ID.String()] = c + } + + // Get Topics of this repo + renderRepoTopics(ctx) + if ctx.Written() { + return + } + + renderBlame(ctx, blameParts, commitNames) + + ctx.HTML(http.StatusOK, tplBlame) +} + +func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames map[string]models.UserCommit) { + repoLink := ctx.Repo.RepoLink + + var lines = make([]string, 0) + + var commitInfo bytes.Buffer + var lineNumbers bytes.Buffer + var codeLines bytes.Buffer + + var i = 0 + for pi, part := range blameParts { + for index, line := range part.Lines { + i++ + lines = append(lines, line) + + var attr = "" + if len(part.Lines)-1 == index && len(blameParts)-1 != pi { + attr = " bottom-line" + } + commit := commitNames[part.Sha] + if index == 0 { + // User avatar image + commitSince := timeutil.TimeSinceUnix(timeutil.TimeStamp(commit.Author.When.Unix()), ctx.Data["Lang"].(string)) + + var avatar string + if commit.User != nil { + avatar = string(templates.Avatar(commit.User, 18, "mr-3")) + } else { + avatar = string(templates.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "mr-3")) + } + + commitInfo.WriteString(fmt.Sprintf(`<div class="blame-info%s"><div class="blame-data"><div class="blame-avatar">%s</div><div class="blame-message"><a href="%s/commit/%s" title="%[5]s">%[5]s</a></div><div class="blame-time">%s</div></div></div>`, attr, avatar, repoLink, part.Sha, html.EscapeString(commit.CommitMessage), commitSince)) + } else { + commitInfo.WriteString(fmt.Sprintf(`<div class="blame-info%s">​</div>`, attr)) + } + + //Line number + if len(part.Lines)-1 == index && len(blameParts)-1 != pi { + lineNumbers.WriteString(fmt.Sprintf(`<span id="L%d" data-line-number="%d" class="bottom-line"></span>`, i, i)) + } else { + lineNumbers.WriteString(fmt.Sprintf(`<span id="L%d" data-line-number="%d"></span>`, i, i)) + } + + if i != len(lines)-1 { + line += "\n" + } + fileName := fmt.Sprintf("%v", ctx.Data["FileName"]) + line = highlight.Code(fileName, line) + line = `<code class="code-inner">` + line + `</code>` + if len(part.Lines)-1 == index && len(blameParts)-1 != pi { + codeLines.WriteString(fmt.Sprintf(`<li class="L%d bottom-line" rel="L%d">%s</li>`, i, i, line)) + } else { + codeLines.WriteString(fmt.Sprintf(`<li class="L%d" rel="L%d">%s</li>`, i, i, line)) + } + } + } + + ctx.Data["BlameContent"] = gotemplate.HTML(codeLines.String()) + ctx.Data["BlameCommitInfo"] = gotemplate.HTML(commitInfo.String()) + ctx.Data["BlameLineNums"] = gotemplate.HTML(lineNumbers.String()) +} diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go new file mode 100644 index 0000000000..4625b1a272 --- /dev/null +++ b/routers/web/repo/branch.go @@ -0,0 +1,407 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 repo + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repofiles" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/forms" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplBranch base.TplName = "repo/branch/list" +) + +// Branch contains the branch information +type Branch struct { + Name string + Commit *git.Commit + IsProtected bool + IsDeleted bool + IsIncluded bool + DeletedBranch *models.DeletedBranch + CommitsAhead int + CommitsBehind int + LatestPullRequest *models.PullRequest + MergeMovedOn bool +} + +// Branches render repository branch page +func Branches(ctx *context.Context) { + ctx.Data["Title"] = "Branches" + ctx.Data["IsRepoToolbarBranches"] = true + ctx.Data["DefaultBranch"] = ctx.Repo.Repository.DefaultBranch + ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls() + ctx.Data["IsWriter"] = ctx.Repo.CanWrite(models.UnitTypeCode) + ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror + ctx.Data["CanPull"] = ctx.Repo.CanWrite(models.UnitTypeCode) || (ctx.IsSigned && ctx.User.HasForkedRepo(ctx.Repo.Repository.ID)) + ctx.Data["PageIsViewCode"] = true + ctx.Data["PageIsBranches"] = true + + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + limit := ctx.QueryInt("limit") + if limit <= 0 || limit > git.BranchesRangeSize { + limit = git.BranchesRangeSize + } + + skip := (page - 1) * limit + log.Debug("Branches: skip: %d limit: %d", skip, limit) + branches, branchesCount := loadBranches(ctx, skip, limit) + if ctx.Written() { + return + } + ctx.Data["Branches"] = branches + pager := context.NewPagination(int(branchesCount), git.BranchesRangeSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplBranch) +} + +// DeleteBranchPost responses for delete merged branch +func DeleteBranchPost(ctx *context.Context) { + defer redirect(ctx) + branchName := ctx.Query("name") + + if err := repo_service.DeleteBranch(ctx.User, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil { + switch { + case git.IsErrBranchNotExist(err): + log.Debug("DeleteBranch: Can't delete non existing branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName)) + case errors.Is(err, repo_service.ErrBranchIsDefault): + log.Debug("DeleteBranch: Can't delete default branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.default_deletion_failed", branchName)) + case errors.Is(err, repo_service.ErrBranchIsProtected): + log.Debug("DeleteBranch: Can't delete protected branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.protected_deletion_failed", branchName)) + default: + log.Error("DeleteBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName)) + } + + return + } + + ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", branchName)) +} + +// RestoreBranchPost responses for delete merged branch +func RestoreBranchPost(ctx *context.Context) { + defer redirect(ctx) + + branchID := ctx.QueryInt64("branch_id") + branchName := ctx.Query("name") + + deletedBranch, err := ctx.Repo.Repository.GetDeletedBranchByID(branchID) + if err != nil { + log.Error("GetDeletedBranchByID: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName)) + return + } + + if err := git.Push(ctx.Repo.Repository.RepoPath(), git.PushOptions{ + Remote: ctx.Repo.Repository.RepoPath(), + Branch: fmt.Sprintf("%s:%s%s", deletedBranch.Commit, git.BranchPrefix, deletedBranch.Name), + Env: models.PushingEnvironment(ctx.User, ctx.Repo.Repository), + }); err != nil { + if strings.Contains(err.Error(), "already exists") { + log.Debug("RestoreBranch: Can't restore branch '%s', since one with same name already exist", deletedBranch.Name) + ctx.Flash.Error(ctx.Tr("repo.branch.already_exists", deletedBranch.Name)) + return + } + log.Error("RestoreBranch: CreateBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", deletedBranch.Name)) + return + } + + // Don't return error below this + if err := repo_service.PushUpdate( + &repo_module.PushUpdateOptions{ + RefFullName: git.BranchPrefix + deletedBranch.Name, + OldCommitID: git.EmptySHA, + NewCommitID: deletedBranch.Commit, + PusherID: ctx.User.ID, + PusherName: ctx.User.Name, + RepoUserName: ctx.Repo.Owner.Name, + RepoName: ctx.Repo.Repository.Name, + }); err != nil { + log.Error("RestoreBranch: Update: %v", err) + } + + ctx.Flash.Success(ctx.Tr("repo.branch.restore_success", deletedBranch.Name)) +} + +func redirect(ctx *context.Context) { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/branches", + }) +} + +// loadBranches loads branches from the repository limited by page & pageSize. +// NOTE: May write to context on error. +func loadBranches(ctx *context.Context, skip, limit int) ([]*Branch, int) { + defaultBranch, err := repo_module.GetBranch(ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch) + if err != nil { + log.Error("loadBranches: get default branch: %v", err) + ctx.ServerError("GetDefaultBranch", err) + return nil, 0 + } + + rawBranches, totalNumOfBranches, err := repo_module.GetBranches(ctx.Repo.Repository, skip, limit) + if err != nil { + log.Error("GetBranches: %v", err) + ctx.ServerError("GetBranches", err) + return nil, 0 + } + + protectedBranches, err := ctx.Repo.Repository.GetProtectedBranches() + if err != nil { + ctx.ServerError("GetProtectedBranches", err) + return nil, 0 + } + + repoIDToRepo := map[int64]*models.Repository{} + repoIDToRepo[ctx.Repo.Repository.ID] = ctx.Repo.Repository + + repoIDToGitRepo := map[int64]*git.Repository{} + repoIDToGitRepo[ctx.Repo.Repository.ID] = ctx.Repo.GitRepo + + var branches []*Branch + for i := range rawBranches { + if rawBranches[i].Name == defaultBranch.Name { + // Skip default branch + continue + } + + var branch = loadOneBranch(ctx, rawBranches[i], protectedBranches, repoIDToRepo, repoIDToGitRepo) + if branch == nil { + return nil, 0 + } + + branches = append(branches, branch) + } + + // Always add the default branch + log.Debug("loadOneBranch: load default: '%s'", defaultBranch.Name) + branches = append(branches, loadOneBranch(ctx, defaultBranch, protectedBranches, repoIDToRepo, repoIDToGitRepo)) + + if ctx.Repo.CanWrite(models.UnitTypeCode) { + deletedBranches, err := getDeletedBranches(ctx) + if err != nil { + ctx.ServerError("getDeletedBranches", err) + return nil, 0 + } + branches = append(branches, deletedBranches...) + } + + return branches, totalNumOfBranches - 1 +} + +func loadOneBranch(ctx *context.Context, rawBranch *git.Branch, protectedBranches []*models.ProtectedBranch, + repoIDToRepo map[int64]*models.Repository, + repoIDToGitRepo map[int64]*git.Repository) *Branch { + log.Trace("loadOneBranch: '%s'", rawBranch.Name) + + commit, err := rawBranch.GetCommit() + if err != nil { + ctx.ServerError("GetCommit", err) + return nil + } + + branchName := rawBranch.Name + var isProtected bool + for _, b := range protectedBranches { + if b.BranchName == branchName { + isProtected = true + break + } + } + + divergence, divergenceError := repofiles.CountDivergingCommits(ctx.Repo.Repository, git.BranchPrefix+branchName) + if divergenceError != nil { + ctx.ServerError("CountDivergingCommits", divergenceError) + return nil + } + + pr, err := models.GetLatestPullRequestByHeadInfo(ctx.Repo.Repository.ID, branchName) + if err != nil { + ctx.ServerError("GetLatestPullRequestByHeadInfo", err) + return nil + } + headCommit := commit.ID.String() + + mergeMovedOn := false + if pr != nil { + pr.HeadRepo = ctx.Repo.Repository + if err := pr.LoadIssue(); err != nil { + ctx.ServerError("pr.LoadIssue", err) + return nil + } + if repo, ok := repoIDToRepo[pr.BaseRepoID]; ok { + pr.BaseRepo = repo + } else if err := pr.LoadBaseRepo(); err != nil { + ctx.ServerError("pr.LoadBaseRepo", err) + return nil + } else { + repoIDToRepo[pr.BaseRepoID] = pr.BaseRepo + } + pr.Issue.Repo = pr.BaseRepo + + if pr.HasMerged { + baseGitRepo, ok := repoIDToGitRepo[pr.BaseRepoID] + if !ok { + baseGitRepo, err = git.OpenRepository(pr.BaseRepo.RepoPath()) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer baseGitRepo.Close() + repoIDToGitRepo[pr.BaseRepoID] = baseGitRepo + } + pullCommit, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("GetBranchCommitID", err) + return nil + } + if err == nil && headCommit != pullCommit { + // the head has moved on from the merge - we shouldn't delete + mergeMovedOn = true + } + } + } + + isIncluded := divergence.Ahead == 0 && ctx.Repo.Repository.DefaultBranch != branchName + return &Branch{ + Name: branchName, + Commit: commit, + IsProtected: isProtected, + IsIncluded: isIncluded, + CommitsAhead: divergence.Ahead, + CommitsBehind: divergence.Behind, + LatestPullRequest: pr, + MergeMovedOn: mergeMovedOn, + } +} + +func getDeletedBranches(ctx *context.Context) ([]*Branch, error) { + branches := []*Branch{} + + deletedBranches, err := ctx.Repo.Repository.GetDeletedBranches() + if err != nil { + return branches, err + } + + for i := range deletedBranches { + deletedBranches[i].LoadUser() + branches = append(branches, &Branch{ + Name: deletedBranches[i].Name, + IsDeleted: true, + DeletedBranch: deletedBranches[i], + }) + } + + return branches, nil +} + +// CreateBranch creates new branch in repository +func CreateBranch(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewBranchForm) + if !ctx.Repo.CanCreateBranch() { + ctx.NotFound("CreateBranch", nil) + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + var err error + + if form.CreateTag { + if ctx.Repo.IsViewTag { + err = release_service.CreateNewTag(ctx.User, ctx.Repo.Repository, ctx.Repo.CommitID, form.NewBranchName, "") + } else { + err = release_service.CreateNewTag(ctx.User, ctx.Repo.Repository, ctx.Repo.BranchName, form.NewBranchName, "") + } + } else if ctx.Repo.IsViewBranch { + err = repo_module.CreateNewBranch(ctx.User, ctx.Repo.Repository, ctx.Repo.BranchName, form.NewBranchName) + } else if ctx.Repo.IsViewTag { + err = repo_module.CreateNewBranchFromCommit(ctx.User, ctx.Repo.Repository, ctx.Repo.CommitID, form.NewBranchName) + } else { + err = repo_module.CreateNewBranchFromCommit(ctx.User, ctx.Repo.Repository, ctx.Repo.BranchName, form.NewBranchName) + } + if err != nil { + if models.IsErrTagAlreadyExists(err) { + e := err.(models.ErrTagAlreadyExists) + ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if models.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) { + ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if models.IsErrBranchNameConflict(err) { + e := err.(models.ErrBranchNameConflict) + ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if git.IsErrPushRejected(err) { + e := err.(*git.ErrPushRejected) + if len(e.Message) == 0 { + ctx.Flash.Error(ctx.Tr("repo.editor.push_rejected_no_message")) + } else { + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(e.Message), + }) + if err != nil { + ctx.ServerError("UpdatePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + } + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + ctx.ServerError("CreateNewBranch", err) + return + } + + if form.CreateTag { + ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.NewBranchName)) + return + } + + ctx.Flash.Success(ctx.Tr("repo.branch.create_success", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(form.NewBranchName)) +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go new file mode 100644 index 0000000000..3e6148bcbb --- /dev/null +++ b/routers/web/repo/commit.go @@ -0,0 +1,401 @@ +// 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 repo + +import ( + "errors" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitgraph" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/gitdiff" +) + +const ( + tplCommits base.TplName = "repo/commits" + tplGraph base.TplName = "repo/graph" + tplGraphDiv base.TplName = "repo/graph/div" + tplCommitPage base.TplName = "repo/commit_page" +) + +// RefCommits render commits page +func RefCommits(ctx *context.Context) { + switch { + case len(ctx.Repo.TreePath) == 0: + Commits(ctx) + case ctx.Repo.TreePath == "search": + SearchCommits(ctx) + default: + FileHistory(ctx) + } +} + +// Commits render branch's commits +func Commits(ctx *context.Context) { + ctx.Data["PageIsCommits"] = true + if ctx.Repo.Commit == nil { + ctx.NotFound("Commit not found", nil) + return + } + ctx.Data["PageIsViewCode"] = true + + commitsCount, err := ctx.Repo.GetCommitsCount() + if err != nil { + ctx.ServerError("GetCommitsCount", err) + return + } + + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + pageSize := ctx.QueryInt("limit") + if pageSize <= 0 { + pageSize = git.CommitsRangeSize + } + + // Both `git log branchName` and `git log commitId` work. + commits, err := ctx.Repo.Commit.CommitsByRange(page, pageSize) + if err != nil { + ctx.ServerError("CommitsByRange", err) + return + } + commits = models.ValidateCommitsWithEmails(commits) + commits = models.ParseCommitsWithSignature(commits, ctx.Repo.Repository) + commits = models.ParseCommitsWithStatus(commits, ctx.Repo.Repository) + ctx.Data["Commits"] = commits + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commitsCount + ctx.Data["Branch"] = ctx.Repo.BranchName + + pager := context.NewPagination(int(commitsCount), git.CommitsRangeSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplCommits) +} + +// Graph render commit graph - show commits from all branches. +func Graph(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.commit_graph") + ctx.Data["PageIsCommits"] = true + ctx.Data["PageIsViewCode"] = true + mode := strings.ToLower(ctx.QueryTrim("mode")) + if mode != "monochrome" { + mode = "color" + } + ctx.Data["Mode"] = mode + hidePRRefs := ctx.QueryBool("hide-pr-refs") + ctx.Data["HidePRRefs"] = hidePRRefs + branches := ctx.QueryStrings("branch") + realBranches := make([]string, len(branches)) + copy(realBranches, branches) + for i, branch := range realBranches { + if strings.HasPrefix(branch, "--") { + realBranches[i] = "refs/heads/" + branch + } + } + ctx.Data["SelectedBranches"] = realBranches + files := ctx.QueryStrings("file") + + commitsCount, err := ctx.Repo.GetCommitsCount() + if err != nil { + ctx.ServerError("GetCommitsCount", err) + return + } + + graphCommitsCount, err := ctx.Repo.GetCommitGraphsCount(hidePRRefs, realBranches, files) + if err != nil { + log.Warn("GetCommitGraphsCount error for generate graph exclude prs: %t branches: %s in %-v, Will Ignore branches and try again. Underlying Error: %v", hidePRRefs, branches, ctx.Repo.Repository, err) + realBranches = []string{} + branches = []string{} + graphCommitsCount, err = ctx.Repo.GetCommitGraphsCount(hidePRRefs, realBranches, files) + if err != nil { + ctx.ServerError("GetCommitGraphsCount", err) + return + } + } + + page := ctx.QueryInt("page") + + graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0, hidePRRefs, realBranches, files) + if err != nil { + ctx.ServerError("GetCommitGraph", err) + return + } + + if err := graph.LoadAndProcessCommits(ctx.Repo.Repository, ctx.Repo.GitRepo); err != nil { + ctx.ServerError("LoadAndProcessCommits", err) + return + } + + ctx.Data["Graph"] = graph + + gitRefs, err := ctx.Repo.GitRepo.GetRefs() + if err != nil { + ctx.ServerError("GitRepo.GetRefs", err) + return + } + + ctx.Data["AllRefs"] = gitRefs + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commitsCount + ctx.Data["Branch"] = ctx.Repo.BranchName + paginator := context.NewPagination(int(graphCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) + paginator.AddParam(ctx, "mode", "Mode") + paginator.AddParam(ctx, "hide-pr-refs", "HidePRRefs") + for _, branch := range branches { + paginator.AddParamString("branch", branch) + } + for _, file := range files { + paginator.AddParamString("file", file) + } + ctx.Data["Page"] = paginator + if ctx.QueryBool("div-only") { + ctx.HTML(http.StatusOK, tplGraphDiv) + return + } + + ctx.HTML(http.StatusOK, tplGraph) +} + +// SearchCommits render commits filtered by keyword +func SearchCommits(ctx *context.Context) { + ctx.Data["PageIsCommits"] = true + ctx.Data["PageIsViewCode"] = true + + query := strings.Trim(ctx.Query("q"), " ") + if len(query) == 0 { + ctx.Redirect(ctx.Repo.RepoLink + "/commits/" + ctx.Repo.BranchNameSubURL()) + return + } + + all := ctx.QueryBool("all") + opts := git.NewSearchCommitsOptions(query, all) + commits, err := ctx.Repo.Commit.SearchCommits(opts) + if err != nil { + ctx.ServerError("SearchCommits", err) + return + } + commits = models.ValidateCommitsWithEmails(commits) + commits = models.ParseCommitsWithSignature(commits, ctx.Repo.Repository) + commits = models.ParseCommitsWithStatus(commits, ctx.Repo.Repository) + ctx.Data["Commits"] = commits + + ctx.Data["Keyword"] = query + if all { + ctx.Data["All"] = "checked" + } + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commits.Len() + ctx.Data["Branch"] = ctx.Repo.BranchName + ctx.HTML(http.StatusOK, tplCommits) +} + +// FileHistory show a file's reversions +func FileHistory(ctx *context.Context) { + ctx.Data["IsRepoToolbarCommits"] = true + + fileName := ctx.Repo.TreePath + if len(fileName) == 0 { + Commits(ctx) + return + } + + branchName := ctx.Repo.BranchName + commitsCount, err := ctx.Repo.GitRepo.FileCommitsCount(branchName, fileName) + if err != nil { + ctx.ServerError("FileCommitsCount", err) + return + } else if commitsCount == 0 { + ctx.NotFound("FileCommitsCount", nil) + return + } + + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(branchName, fileName, page) + if err != nil { + ctx.ServerError("CommitsByFileAndRange", err) + return + } + commits = models.ValidateCommitsWithEmails(commits) + commits = models.ParseCommitsWithSignature(commits, ctx.Repo.Repository) + commits = models.ParseCommitsWithStatus(commits, ctx.Repo.Repository) + ctx.Data["Commits"] = commits + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["FileName"] = fileName + ctx.Data["CommitCount"] = commitsCount + ctx.Data["Branch"] = branchName + + pager := context.NewPagination(int(commitsCount), git.CommitsRangeSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplCommits) +} + +// Diff show different from current commit to previous commit +func Diff(ctx *context.Context) { + ctx.Data["PageIsDiff"] = true + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["RequireTribute"] = true + + userName := ctx.Repo.Owner.Name + repoName := ctx.Repo.Repository.Name + commitID := ctx.Params(":sha") + var ( + gitRepo *git.Repository + err error + repoPath string + ) + + if ctx.Data["PageIsWiki"] != nil { + gitRepo, err = git.OpenRepository(ctx.Repo.Repository.WikiPath()) + if err != nil { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + return + } + repoPath = ctx.Repo.Repository.WikiPath() + } else { + gitRepo = ctx.Repo.GitRepo + repoPath = models.RepoPath(userName, repoName) + } + + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Repo.GitRepo.GetCommit", err) + } else { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + } + return + } + if len(commitID) != 40 { + commitID = commit.ID.String() + } + + statuses, err := models.GetLatestCommitStatus(ctx.Repo.Repository.ID, commitID, models.ListOptions{}) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + + ctx.Data["CommitStatus"] = models.CalcCommitStatus(statuses) + ctx.Data["CommitStatuses"] = statuses + + diff, err := gitdiff.GetDiffCommitWithWhitespaceBehavior(repoPath, + commitID, setting.Git.MaxGitDiffLines, + setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, + gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + if err != nil { + ctx.NotFound("GetDiffCommitWithWhitespaceBehavior", err) + return + } + + parents := make([]string, commit.ParentCount()) + for i := 0; i < commit.ParentCount(); i++ { + sha, err := commit.ParentID(i) + if err != nil { + ctx.NotFound("repo.Diff", err) + return + } + parents[i] = sha.String() + } + + ctx.Data["CommitID"] = commitID + ctx.Data["AfterCommitID"] = commitID + ctx.Data["Username"] = userName + ctx.Data["Reponame"] = repoName + + var parentCommit *git.Commit + if commit.ParentCount() > 0 { + parentCommit, err = gitRepo.GetCommit(parents[0]) + if err != nil { + ctx.NotFound("GetParentCommit", err) + return + } + } + headTarget := path.Join(userName, repoName) + setCompareContext(ctx, parentCommit, commit, headTarget) + ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID) + ctx.Data["Commit"] = commit + verification := models.ParseCommitWithSignature(commit) + ctx.Data["Verification"] = verification + ctx.Data["Author"] = models.ValidateCommitWithEmail(commit) + ctx.Data["Diff"] = diff + ctx.Data["Parents"] = parents + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + if err := models.CalculateTrustStatus(verification, ctx.Repo.Repository, nil); err != nil { + ctx.ServerError("CalculateTrustStatus", err) + return + } + + note := &git.Note{} + err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note) + if err == nil { + ctx.Data["Note"] = string(charset.ToUTF8WithFallback(note.Message)) + ctx.Data["NoteCommit"] = note.Commit + ctx.Data["NoteAuthor"] = models.ValidateCommitWithEmail(note.Commit) + } + + ctx.Data["BranchName"], err = commit.GetBranchName() + if err != nil { + ctx.ServerError("commit.GetBranchName", err) + return + } + + ctx.Data["TagName"], err = commit.GetTagName() + if err != nil { + ctx.ServerError("commit.GetTagName", err) + return + } + ctx.HTML(http.StatusOK, tplCommitPage) +} + +// RawDiff dumps diff results of repository in given commit ID to io.Writer +func RawDiff(ctx *context.Context) { + var repoPath string + if ctx.Data["PageIsWiki"] != nil { + repoPath = ctx.Repo.Repository.WikiPath() + } else { + repoPath = models.RepoPath(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + } + if err := git.GetRawDiff( + repoPath, + ctx.Params(":sha"), + git.RawDiffType(ctx.Params(":ext")), + ctx.Resp, + ); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", + errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } +} diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go new file mode 100644 index 0000000000..f53a31769d --- /dev/null +++ b/routers/web/repo/compare.go @@ -0,0 +1,787 @@ +// 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 repo + +import ( + "bufio" + "encoding/csv" + "errors" + "fmt" + "html" + "net/http" + "path" + "path/filepath" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/context" + csv_module "code.gitea.io/gitea/modules/csv" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/upload" + "code.gitea.io/gitea/services/gitdiff" +) + +const ( + tplCompare base.TplName = "repo/diff/compare" + tplBlobExcerpt base.TplName = "repo/diff/blob_excerpt" +) + +// setCompareContext sets context data. +func setCompareContext(ctx *context.Context, base *git.Commit, head *git.Commit, headTarget string) { + ctx.Data["BaseCommit"] = base + ctx.Data["HeadCommit"] = head + + ctx.Data["GetBlobByPathForCommit"] = func(commit *git.Commit, path string) *git.Blob { + if commit == nil { + return nil + } + + blob, err := commit.GetBlobByPath(path) + if err != nil { + return nil + } + return blob + } + + setPathsCompareContext(ctx, base, head, headTarget) + setImageCompareContext(ctx) + setCsvCompareContext(ctx) +} + +// setPathsCompareContext sets context data for source and raw paths +func setPathsCompareContext(ctx *context.Context, base *git.Commit, head *git.Commit, headTarget string) { + sourcePath := setting.AppSubURL + "/%s/src/commit/%s" + rawPath := setting.AppSubURL + "/%s/raw/commit/%s" + + ctx.Data["SourcePath"] = fmt.Sprintf(sourcePath, headTarget, head.ID) + ctx.Data["RawPath"] = fmt.Sprintf(rawPath, headTarget, head.ID) + if base != nil { + baseTarget := path.Join(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + ctx.Data["BeforeSourcePath"] = fmt.Sprintf(sourcePath, baseTarget, base.ID) + ctx.Data["BeforeRawPath"] = fmt.Sprintf(rawPath, baseTarget, base.ID) + } +} + +// setImageCompareContext sets context data that is required by image compare template +func setImageCompareContext(ctx *context.Context) { + ctx.Data["IsBlobAnImage"] = func(blob *git.Blob) bool { + if blob == nil { + return false + } + + st, err := blob.GuessContentType() + if err != nil { + log.Error("GuessContentType failed: %v", err) + return false + } + return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()) + } +} + +// setCsvCompareContext sets context data that is required by the CSV compare template +func setCsvCompareContext(ctx *context.Context) { + ctx.Data["IsCsvFile"] = func(diffFile *gitdiff.DiffFile) bool { + extension := strings.ToLower(filepath.Ext(diffFile.Name)) + return extension == ".csv" || extension == ".tsv" + } + + type CsvDiffResult struct { + Sections []*gitdiff.TableDiffSection + Error string + } + + ctx.Data["CreateCsvDiff"] = func(diffFile *gitdiff.DiffFile, baseCommit *git.Commit, headCommit *git.Commit) CsvDiffResult { + if diffFile == nil || baseCommit == nil || headCommit == nil { + return CsvDiffResult{nil, ""} + } + + errTooLarge := errors.New(ctx.Locale.Tr("repo.error.csv.too_large")) + + csvReaderFromCommit := func(c *git.Commit) (*csv.Reader, error) { + blob, err := c.GetBlobByPath(diffFile.Name) + if err != nil { + return nil, err + } + + if setting.UI.CSV.MaxFileSize != 0 && setting.UI.CSV.MaxFileSize < blob.Size() { + return nil, errTooLarge + } + + reader, err := blob.DataAsync() + if err != nil { + return nil, err + } + defer reader.Close() + + return csv_module.CreateReaderAndGuessDelimiter(charset.ToUTF8WithFallbackReader(reader)) + } + + baseReader, err := csvReaderFromCommit(baseCommit) + if err == errTooLarge { + return CsvDiffResult{nil, err.Error()} + } + headReader, err := csvReaderFromCommit(headCommit) + if err == errTooLarge { + return CsvDiffResult{nil, err.Error()} + } + + sections, err := gitdiff.CreateCsvDiff(diffFile, baseReader, headReader) + if err != nil { + errMessage, err := csv_module.FormatError(err, ctx.Locale) + if err != nil { + log.Error("RenderCsvDiff failed: %v", err) + return CsvDiffResult{nil, ""} + } + return CsvDiffResult{nil, errMessage} + } + return CsvDiffResult{sections, ""} + } +} + +// ParseCompareInfo parse compare info between two commit for preparing comparing references +func ParseCompareInfo(ctx *context.Context) (*models.User, *models.Repository, *git.Repository, *git.CompareInfo, string, string) { + baseRepo := ctx.Repo.Repository + + // Get compared branches information + // A full compare url is of the form: + // + // 1. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headBranch} + // 2. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}:{:headBranch} + // 3. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}/{:headRepoName}:{:headBranch} + // + // Here we obtain the infoPath "{:baseBranch}...[{:headOwner}/{:headRepoName}:]{:headBranch}" as ctx.Params("*") + // with the :baseRepo in ctx.Repo. + // + // Note: Generally :headRepoName is not provided here - we are only passed :headOwner. + // + // How do we determine the :headRepo? + // + // 1. If :headOwner is not set then the :headRepo = :baseRepo + // 2. If :headOwner is set - then look for the fork of :baseRepo owned by :headOwner + // 3. But... :baseRepo could be a fork of :headOwner's repo - so check that + // 4. Now, :baseRepo and :headRepos could be forks of the same repo - so check that + // + // format: <base branch>...[<head repo>:]<head branch> + // base<-head: master...head:feature + // same repo: master...feature + + var ( + headUser *models.User + headRepo *models.Repository + headBranch string + isSameRepo bool + infoPath string + err error + ) + infoPath = ctx.Params("*") + infos := strings.SplitN(infoPath, "...", 2) + if len(infos) != 2 { + log.Trace("ParseCompareInfo[%d]: not enough compared branches information %s", baseRepo.ID, infos) + ctx.NotFound("CompareAndPullRequest", nil) + return nil, nil, nil, nil, "", "" + } + + ctx.Data["BaseName"] = baseRepo.OwnerName + baseBranch := infos[0] + ctx.Data["BaseBranch"] = baseBranch + + // If there is no head repository, it means compare between same repository. + headInfos := strings.Split(infos[1], ":") + if len(headInfos) == 1 { + isSameRepo = true + headUser = ctx.Repo.Owner + headBranch = headInfos[0] + + } else if len(headInfos) == 2 { + headInfosSplit := strings.Split(headInfos[0], "/") + if len(headInfosSplit) == 1 { + headUser, err = models.GetUserByName(headInfos[0]) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", nil) + } else { + ctx.ServerError("GetUserByName", err) + } + return nil, nil, nil, nil, "", "" + } + headBranch = headInfos[1] + isSameRepo = headUser.ID == ctx.Repo.Owner.ID + if isSameRepo { + headRepo = baseRepo + } + } else { + headRepo, err = models.GetRepositoryByOwnerAndName(headInfosSplit[0], headInfosSplit[1]) + if err != nil { + if models.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByOwnerAndName", nil) + } else { + ctx.ServerError("GetRepositoryByOwnerAndName", err) + } + return nil, nil, nil, nil, "", "" + } + if err := headRepo.GetOwner(); err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", nil) + } else { + ctx.ServerError("GetUserByName", err) + } + return nil, nil, nil, nil, "", "" + } + headBranch = headInfos[1] + headUser = headRepo.Owner + isSameRepo = headRepo.ID == ctx.Repo.Repository.ID + } + } else { + ctx.NotFound("CompareAndPullRequest", nil) + return nil, nil, nil, nil, "", "" + } + ctx.Data["HeadUser"] = headUser + ctx.Data["HeadBranch"] = headBranch + ctx.Repo.PullRequest.SameRepo = isSameRepo + + // Check if base branch is valid. + baseIsCommit := ctx.Repo.GitRepo.IsCommitExist(baseBranch) + baseIsBranch := ctx.Repo.GitRepo.IsBranchExist(baseBranch) + baseIsTag := ctx.Repo.GitRepo.IsTagExist(baseBranch) + if !baseIsCommit && !baseIsBranch && !baseIsTag { + // Check if baseBranch is short sha commit hash + if baseCommit, _ := ctx.Repo.GitRepo.GetCommit(baseBranch); baseCommit != nil { + baseBranch = baseCommit.ID.String() + ctx.Data["BaseBranch"] = baseBranch + baseIsCommit = true + } else { + ctx.NotFound("IsRefExist", nil) + return nil, nil, nil, nil, "", "" + } + } + ctx.Data["BaseIsCommit"] = baseIsCommit + ctx.Data["BaseIsBranch"] = baseIsBranch + ctx.Data["BaseIsTag"] = baseIsTag + ctx.Data["IsPull"] = true + + // Now we have the repository that represents the base + + // The current base and head repositories and branches may not + // actually be the intended branches that the user wants to + // create a pull-request from - but also determining the head + // repo is difficult. + + // We will want therefore to offer a few repositories to set as + // our base and head + + // 1. First if the baseRepo is a fork get the "RootRepo" it was + // forked from + var rootRepo *models.Repository + if baseRepo.IsFork { + err = baseRepo.GetBaseRepo() + if err != nil { + if !models.IsErrRepoNotExist(err) { + ctx.ServerError("Unable to find root repo", err) + return nil, nil, nil, nil, "", "" + } + } else { + rootRepo = baseRepo.BaseRepo + } + } + + // 2. Now if the current user is not the owner of the baseRepo, + // check if they have a fork of the base repo and offer that as + // "OwnForkRepo" + var ownForkRepo *models.Repository + if ctx.User != nil && baseRepo.OwnerID != ctx.User.ID { + repo, has := models.HasForkedRepo(ctx.User.ID, baseRepo.ID) + if has { + ownForkRepo = repo + ctx.Data["OwnForkRepo"] = ownForkRepo + } + } + + has := headRepo != nil + // 3. If the base is a forked from "RootRepo" and the owner of + // the "RootRepo" is the :headUser - set headRepo to that + if !has && rootRepo != nil && rootRepo.OwnerID == headUser.ID { + headRepo = rootRepo + has = true + } + + // 4. If the ctx.User has their own fork of the baseRepo and the headUser is the ctx.User + // set the headRepo to the ownFork + if !has && ownForkRepo != nil && ownForkRepo.OwnerID == headUser.ID { + headRepo = ownForkRepo + has = true + } + + // 5. If the headOwner has a fork of the baseRepo - use that + if !has { + headRepo, has = models.HasForkedRepo(headUser.ID, baseRepo.ID) + } + + // 6. If the baseRepo is a fork and the headUser has a fork of that use that + if !has && baseRepo.IsFork { + headRepo, has = models.HasForkedRepo(headUser.ID, baseRepo.ForkID) + } + + // 7. Otherwise if we're not the same repo and haven't found a repo give up + if !isSameRepo && !has { + ctx.Data["PageIsComparePull"] = false + } + + // 8. Finally open the git repo + var headGitRepo *git.Repository + if isSameRepo { + headRepo = ctx.Repo.Repository + headGitRepo = ctx.Repo.GitRepo + } else if has { + headGitRepo, err = git.OpenRepository(headRepo.RepoPath()) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil, nil, nil, nil, "", "" + } + defer headGitRepo.Close() + } + + ctx.Data["HeadRepo"] = headRepo + + // Now we need to assert that the ctx.User has permission to read + // the baseRepo's code and pulls + // (NOT headRepo's) + permBase, err := models.GetUserRepoPermission(baseRepo, ctx.User) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil, nil, nil, nil, "", "" + } + if !permBase.CanRead(models.UnitTypeCode) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in baseRepo has Permissions: %-+v", + ctx.User, + baseRepo, + permBase) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil, nil, nil, nil, "", "" + } + + // If we're not merging from the same repo: + if !isSameRepo { + // Assert ctx.User has permission to read headRepo's codes + permHead, err := models.GetUserRepoPermission(headRepo, ctx.User) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil, nil, nil, nil, "", "" + } + if !permHead.CanRead(models.UnitTypeCode) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in headRepo has Permissions: %-+v", + ctx.User, + headRepo, + permHead) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil, nil, nil, nil, "", "" + } + } + + // If we have a rootRepo and it's different from: + // 1. the computed base + // 2. the computed head + // then get the branches of it + if rootRepo != nil && + rootRepo.ID != headRepo.ID && + rootRepo.ID != baseRepo.ID { + perm, branches, tags, err := getBranchesAndTagsForRepo(ctx.User, rootRepo) + if err != nil { + ctx.ServerError("GetBranchesForRepo", err) + return nil, nil, nil, nil, "", "" + } + if perm { + ctx.Data["RootRepo"] = rootRepo + ctx.Data["RootRepoBranches"] = branches + ctx.Data["RootRepoTags"] = tags + } + } + + // If we have a ownForkRepo and it's different from: + // 1. The computed base + // 2. The computed head + // 3. The rootRepo (if we have one) + // then get the branches from it. + if ownForkRepo != nil && + ownForkRepo.ID != headRepo.ID && + ownForkRepo.ID != baseRepo.ID && + (rootRepo == nil || ownForkRepo.ID != rootRepo.ID) { + perm, branches, tags, err := getBranchesAndTagsForRepo(ctx.User, ownForkRepo) + if err != nil { + ctx.ServerError("GetBranchesForRepo", err) + return nil, nil, nil, nil, "", "" + } + if perm { + ctx.Data["OwnForkRepo"] = ownForkRepo + ctx.Data["OwnForkRepoBranches"] = branches + ctx.Data["OwnForkRepoTags"] = tags + } + } + + // Check if head branch is valid. + headIsCommit := headGitRepo.IsCommitExist(headBranch) + headIsBranch := headGitRepo.IsBranchExist(headBranch) + headIsTag := headGitRepo.IsTagExist(headBranch) + if !headIsCommit && !headIsBranch && !headIsTag { + // Check if headBranch is short sha commit hash + if headCommit, _ := headGitRepo.GetCommit(headBranch); headCommit != nil { + headBranch = headCommit.ID.String() + ctx.Data["HeadBranch"] = headBranch + headIsCommit = true + } else { + ctx.NotFound("IsRefExist", nil) + return nil, nil, nil, nil, "", "" + } + } + ctx.Data["HeadIsCommit"] = headIsCommit + ctx.Data["HeadIsBranch"] = headIsBranch + ctx.Data["HeadIsTag"] = headIsTag + + // Treat as pull request if both references are branches + if ctx.Data["PageIsComparePull"] == nil { + ctx.Data["PageIsComparePull"] = headIsBranch && baseIsBranch + } + + if ctx.Data["PageIsComparePull"] == true && !permBase.CanReadIssuesOrPulls(true) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot create/read pull requests in Repo: %-v\nUser in baseRepo has Permissions: %-+v", + ctx.User, + baseRepo, + permBase) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil, nil, nil, nil, "", "" + } + + baseBranchRef := baseBranch + if baseIsBranch { + baseBranchRef = git.BranchPrefix + baseBranch + } else if baseIsTag { + baseBranchRef = git.TagPrefix + baseBranch + } + headBranchRef := headBranch + if headIsBranch { + headBranchRef = git.BranchPrefix + headBranch + } else if headIsTag { + headBranchRef = git.TagPrefix + headBranch + } + + compareInfo, err := headGitRepo.GetCompareInfo(baseRepo.RepoPath(), baseBranchRef, headBranchRef) + if err != nil { + ctx.ServerError("GetCompareInfo", err) + return nil, nil, nil, nil, "", "" + } + ctx.Data["BeforeCommitID"] = compareInfo.MergeBase + + return headUser, headRepo, headGitRepo, compareInfo, baseBranch, headBranch +} + +// PrepareCompareDiff renders compare diff page +func PrepareCompareDiff( + ctx *context.Context, + headUser *models.User, + headRepo *models.Repository, + headGitRepo *git.Repository, + compareInfo *git.CompareInfo, + baseBranch, headBranch string, + whitespaceBehavior string) bool { + + var ( + repo = ctx.Repo.Repository + err error + title string + ) + + // Get diff information. + ctx.Data["CommitRepoLink"] = headRepo.Link() + + headCommitID := compareInfo.HeadCommitID + + ctx.Data["AfterCommitID"] = headCommitID + + if headCommitID == compareInfo.MergeBase { + ctx.Data["IsNothingToCompare"] = true + if unit, err := repo.GetUnit(models.UnitTypePullRequests); err == nil { + config := unit.PullRequestsConfig() + + if !config.AutodetectManualMerge { + allowEmptyPr := !(baseBranch == headBranch && ctx.Repo.Repository.Name == headRepo.Name) + ctx.Data["AllowEmptyPr"] = allowEmptyPr + + return !allowEmptyPr + } + + ctx.Data["AllowEmptyPr"] = false + } + return true + } + + diff, err := gitdiff.GetDiffRangeWithWhitespaceBehavior(models.RepoPath(headUser.Name, headRepo.Name), + compareInfo.MergeBase, headCommitID, setting.Git.MaxGitDiffLines, + setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, whitespaceBehavior) + if err != nil { + ctx.ServerError("GetDiffRangeWithWhitespaceBehavior", err) + return false + } + ctx.Data["Diff"] = diff + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + headCommit, err := headGitRepo.GetCommit(headCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return false + } + + baseGitRepo := ctx.Repo.GitRepo + baseCommitID := compareInfo.BaseCommitID + + baseCommit, err := baseGitRepo.GetCommit(baseCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return false + } + + compareInfo.Commits = models.ValidateCommitsWithEmails(compareInfo.Commits) + compareInfo.Commits = models.ParseCommitsWithSignature(compareInfo.Commits, headRepo) + compareInfo.Commits = models.ParseCommitsWithStatus(compareInfo.Commits, headRepo) + ctx.Data["Commits"] = compareInfo.Commits + ctx.Data["CommitCount"] = compareInfo.Commits.Len() + + if compareInfo.Commits.Len() == 1 { + c := compareInfo.Commits.Front().Value.(models.SignCommitWithStatuses) + title = strings.TrimSpace(c.UserCommit.Summary()) + + body := strings.Split(strings.TrimSpace(c.UserCommit.Message()), "\n") + if len(body) > 1 { + ctx.Data["content"] = strings.Join(body[1:], "\n") + } + } else { + title = headBranch + } + ctx.Data["title"] = title + ctx.Data["Username"] = headUser.Name + ctx.Data["Reponame"] = headRepo.Name + + headTarget := path.Join(headUser.Name, repo.Name) + setCompareContext(ctx, baseCommit, headCommit, headTarget) + + return false +} + +func getBranchesAndTagsForRepo(user *models.User, repo *models.Repository) (bool, []string, []string, error) { + perm, err := models.GetUserRepoPermission(repo, user) + if err != nil { + return false, nil, nil, err + } + if !perm.CanRead(models.UnitTypeCode) { + return false, nil, nil, nil + } + gitRepo, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + return false, nil, nil, err + } + defer gitRepo.Close() + + branches, _, err := gitRepo.GetBranches(0, 0) + if err != nil { + return false, nil, nil, err + } + tags, err := gitRepo.GetTags() + if err != nil { + return false, nil, nil, err + } + return true, branches, tags, nil +} + +// CompareDiff show different from one commit to another commit +func CompareDiff(ctx *context.Context) { + headUser, headRepo, headGitRepo, compareInfo, baseBranch, headBranch := ParseCompareInfo(ctx) + + if ctx.Written() { + return + } + defer headGitRepo.Close() + + nothingToCompare := PrepareCompareDiff(ctx, headUser, headRepo, headGitRepo, compareInfo, baseBranch, headBranch, + gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + if ctx.Written() { + return + } + + baseGitRepo := ctx.Repo.GitRepo + baseTags, err := baseGitRepo.GetTags() + if err != nil { + ctx.ServerError("GetTags", err) + return + } + ctx.Data["Tags"] = baseTags + + headBranches, _, err := headGitRepo.GetBranches(0, 0) + if err != nil { + ctx.ServerError("GetBranches", err) + return + } + ctx.Data["HeadBranches"] = headBranches + + headTags, err := headGitRepo.GetTags() + if err != nil { + ctx.ServerError("GetTags", err) + return + } + ctx.Data["HeadTags"] = headTags + + if ctx.Data["PageIsComparePull"] == true { + pr, err := models.GetUnmergedPullRequest(headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch) + if err != nil { + if !models.IsErrPullRequestNotExist(err) { + ctx.ServerError("GetUnmergedPullRequest", err) + return + } + } else { + ctx.Data["HasPullRequest"] = true + ctx.Data["PullRequest"] = pr + ctx.HTML(http.StatusOK, tplCompareDiff) + return + } + + if !nothingToCompare { + // Setup information for new form. + RetrieveRepoMetas(ctx, ctx.Repo.Repository, true) + if ctx.Written() { + return + } + } + } + beforeCommitID := ctx.Data["BeforeCommitID"].(string) + afterCommitID := ctx.Data["AfterCommitID"].(string) + + ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + "..." + base.ShortSha(afterCommitID) + + ctx.Data["IsRepoToolbarCommits"] = true + ctx.Data["IsDiffCompare"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + setTemplateIfExists(ctx, pullRequestTemplateKey, nil, pullRequestTemplateCandidates) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypePullRequests) + + ctx.HTML(http.StatusOK, tplCompare) +} + +// ExcerptBlob render blob excerpt contents +func ExcerptBlob(ctx *context.Context) { + commitID := ctx.Params("sha") + lastLeft := ctx.QueryInt("last_left") + lastRight := ctx.QueryInt("last_right") + idxLeft := ctx.QueryInt("left") + idxRight := ctx.QueryInt("right") + leftHunkSize := ctx.QueryInt("left_hunk_size") + rightHunkSize := ctx.QueryInt("right_hunk_size") + anchor := ctx.Query("anchor") + direction := ctx.Query("direction") + filePath := ctx.Query("path") + gitRepo := ctx.Repo.GitRepo + chunkSize := gitdiff.BlobExcerptChunkSize + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommit") + return + } + section := &gitdiff.DiffSection{ + FileName: filePath, + Name: filePath, + } + if direction == "up" && (idxLeft-lastLeft) > chunkSize { + idxLeft -= chunkSize + idxRight -= chunkSize + leftHunkSize += chunkSize + rightHunkSize += chunkSize + section.Lines, err = getExcerptLines(commit, filePath, idxLeft-1, idxRight-1, chunkSize) + } else if direction == "down" && (idxLeft-lastLeft) > chunkSize { + section.Lines, err = getExcerptLines(commit, filePath, lastLeft, lastRight, chunkSize) + lastLeft += chunkSize + lastRight += chunkSize + } else { + section.Lines, err = getExcerptLines(commit, filePath, lastLeft, lastRight, idxRight-lastRight-1) + leftHunkSize = 0 + rightHunkSize = 0 + idxLeft = lastLeft + idxRight = lastRight + } + if err != nil { + ctx.Error(http.StatusInternalServerError, "getExcerptLines") + return + } + if idxRight > lastRight { + lineText := " " + if rightHunkSize > 0 || leftHunkSize > 0 { + lineText = fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", idxLeft, leftHunkSize, idxRight, rightHunkSize) + } + lineText = html.EscapeString(lineText) + lineSection := &gitdiff.DiffLine{ + Type: gitdiff.DiffLineSection, + Content: lineText, + SectionInfo: &gitdiff.DiffLineSectionInfo{ + Path: filePath, + LastLeftIdx: lastLeft, + LastRightIdx: lastRight, + LeftIdx: idxLeft, + RightIdx: idxRight, + LeftHunkSize: leftHunkSize, + RightHunkSize: rightHunkSize, + }} + if direction == "up" { + section.Lines = append([]*gitdiff.DiffLine{lineSection}, section.Lines...) + } else if direction == "down" { + section.Lines = append(section.Lines, lineSection) + } + } + ctx.Data["section"] = section + ctx.Data["fileName"] = filePath + ctx.Data["AfterCommitID"] = commitID + ctx.Data["Anchor"] = anchor + ctx.HTML(http.StatusOK, tplBlobExcerpt) +} + +func getExcerptLines(commit *git.Commit, filePath string, idxLeft int, idxRight int, chunkSize int) ([]*gitdiff.DiffLine, error) { + blob, err := commit.Tree.GetBlobByPath(filePath) + if err != nil { + return nil, err + } + reader, err := blob.DataAsync() + if err != nil { + return nil, err + } + defer reader.Close() + scanner := bufio.NewScanner(reader) + var diffLines []*gitdiff.DiffLine + for line := 0; line < idxRight+chunkSize; line++ { + if ok := scanner.Scan(); !ok { + break + } + if line < idxRight { + continue + } + lineText := scanner.Text() + diffLine := &gitdiff.DiffLine{ + LeftIdx: idxLeft + (line - idxRight) + 1, + RightIdx: line + 1, + Type: gitdiff.DiffLinePlain, + Content: " " + lineText, + } + diffLines = append(diffLines, diffLine) + } + return diffLines, nil +} diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go new file mode 100644 index 0000000000..6f43d4b839 --- /dev/null +++ b/routers/web/repo/download.go @@ -0,0 +1,131 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 repo + +import ( + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/routers/common" +) + +// ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary +func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob) error { + if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`) { + return nil + } + + dataRc, err := blob.DataAsync() + if err != nil { + return err + } + closed := false + defer func() { + if closed { + return + } + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + }() + + pointer, _ := lfs.ReadPointer(dataRc) + if pointer.IsValid() { + meta, _ := ctx.Repo.Repository.GetLFSMetaObjectByOid(pointer.Oid) + if meta == nil { + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + closed = true + return common.ServeBlob(ctx, blob) + } + if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) { + return nil + } + lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer) + if err != nil { + return err + } + defer func() { + if err = lfsDataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + }() + return common.ServeData(ctx, ctx.Repo.TreePath, meta.Size, lfsDataRc) + } + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + closed = true + + return common.ServeBlob(ctx, blob) +} + +// SingleDownload download a file by repos path +func SingleDownload(ctx *context.Context) { + blob, err := ctx.Repo.Commit.GetBlobByPath(ctx.Repo.TreePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlobByPath", nil) + } else { + ctx.ServerError("GetBlobByPath", err) + } + return + } + if err = common.ServeBlob(ctx, blob); err != nil { + ctx.ServerError("ServeBlob", err) + } +} + +// SingleDownloadOrLFS download a file by repos path redirecting to LFS if necessary +func SingleDownloadOrLFS(ctx *context.Context) { + blob, err := ctx.Repo.Commit.GetBlobByPath(ctx.Repo.TreePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlobByPath", nil) + } else { + ctx.ServerError("GetBlobByPath", err) + } + return + } + if err = ServeBlobOrLFS(ctx, blob); err != nil { + ctx.ServerError("ServeBlobOrLFS", err) + } +} + +// DownloadByID download a file by sha1 ID +func DownloadByID(ctx *context.Context) { + blob, err := ctx.Repo.GitRepo.GetBlob(ctx.Params("sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlob", nil) + } else { + ctx.ServerError("GetBlob", err) + } + return + } + if err = common.ServeBlob(ctx, blob); err != nil { + ctx.ServerError("ServeBlob", err) + } +} + +// DownloadByIDOrLFS download a file by sha1 ID taking account of LFS +func DownloadByIDOrLFS(ctx *context.Context) { + blob, err := ctx.Repo.GitRepo.GetBlob(ctx.Params("sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlob", nil) + } else { + ctx.ServerError("GetBlob", err) + } + return + } + if err = ServeBlobOrLFS(ctx, blob); err != nil { + ctx.ServerError("ServeBlob", err) + } +} diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go new file mode 100644 index 0000000000..0f978c7b01 --- /dev/null +++ b/routers/web/repo/editor.go @@ -0,0 +1,831 @@ +// Copyright 2016 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 repo + +import ( + "fmt" + "io/ioutil" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repofiles" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/upload" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/forms" + jsoniter "github.com/json-iterator/go" +) + +const ( + tplEditFile base.TplName = "repo/editor/edit" + tplEditDiffPreview base.TplName = "repo/editor/diff_preview" + tplDeleteFile base.TplName = "repo/editor/delete" + tplUploadFile base.TplName = "repo/editor/upload" + + frmCommitChoiceDirect string = "direct" + frmCommitChoiceNewBranch string = "commit-to-new-branch" +) + +func renderCommitRights(ctx *context.Context) bool { + canCommitToBranch, err := ctx.Repo.CanCommitToBranch(ctx.User) + if err != nil { + log.Error("CanCommitToBranch: %v", err) + } + ctx.Data["CanCommitToBranch"] = canCommitToBranch + + return canCommitToBranch.CanCommitToBranch +} + +// getParentTreeFields returns list of parent tree names and corresponding tree paths +// based on given tree path. +func getParentTreeFields(treePath string) (treeNames []string, treePaths []string) { + if len(treePath) == 0 { + return treeNames, treePaths + } + + treeNames = strings.Split(treePath, "/") + treePaths = make([]string, len(treeNames)) + for i := range treeNames { + treePaths[i] = strings.Join(treeNames[:i+1], "/") + } + return treeNames, treePaths +} + +func editFile(ctx *context.Context, isNewFile bool) { + ctx.Data["PageIsEdit"] = true + ctx.Data["IsNewFile"] = isNewFile + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireSimpleMDE"] = true + canCommit := renderCommitRights(ctx) + + treePath := cleanUploadFileName(ctx.Repo.TreePath) + if treePath != ctx.Repo.TreePath { + if isNewFile { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_new", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + } else { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_edit", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + } + return + } + + treeNames, treePaths := getParentTreeFields(ctx.Repo.TreePath) + + if !isNewFile { + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + ctx.NotFoundOrServerError("GetTreeEntryByPath", git.IsErrNotExist, err) + return + } + + // No way to edit a directory online. + if entry.IsDir() { + ctx.NotFound("entry.IsDir", nil) + return + } + + blob := entry.Blob() + if blob.Size() >= setting.UI.MaxDisplayFileSize { + ctx.NotFound("blob.Size", err) + return + } + + dataRc, err := blob.DataAsync() + if err != nil { + ctx.NotFound("blob.Data", err) + return + } + + defer dataRc.Close() + + ctx.Data["FileSize"] = blob.Size() + ctx.Data["FileName"] = blob.Name() + + buf := make([]byte, 1024) + n, _ := dataRc.Read(buf) + buf = buf[:n] + + // Only some file types are editable online as text. + if !typesniffer.DetectContentType(buf).IsRepresentableAsText() { + ctx.NotFound("typesniffer.IsRepresentableAsText", nil) + return + } + + d, _ := ioutil.ReadAll(dataRc) + if err := dataRc.Close(); err != nil { + log.Error("Error whilst closing blob data: %v", err) + } + + buf = append(buf, d...) + if content, err := charset.ToUTF8WithErr(buf); err != nil { + log.Error("ToUTF8WithErr: %v", err) + ctx.Data["FileContent"] = string(buf) + } else { + ctx.Data["FileContent"] = content + } + } else { + treeNames = append(treeNames, "") // Append empty string to allow user name the new file. + } + + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["MarkdownFileExts"] = strings.Join(setting.Markdown.FileExtensions, ",") + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["PreviewableFileModes"] = strings.Join(setting.Repository.Editor.PreviewableFileModes, ",") + ctx.Data["Editorconfig"] = GetEditorConfig(ctx, treePath) + + ctx.HTML(http.StatusOK, tplEditFile) +} + +// GetEditorConfig returns a editorconfig JSON string for given treePath or "null" +func GetEditorConfig(ctx *context.Context, treePath string) string { + ec, err := ctx.Repo.GetEditorconfig() + if err == nil { + def, err := ec.GetDefinitionForFilename(treePath) + if err == nil { + json := jsoniter.ConfigCompatibleWithStandardLibrary + jsonStr, _ := json.Marshal(def) + return string(jsonStr) + } + } + return "null" +} + +// EditFile render edit file page +func EditFile(ctx *context.Context) { + editFile(ctx, false) +} + +// NewFile render create file page +func NewFile(ctx *context.Context) { + editFile(ctx, true) +} + +func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile bool) { + canCommit := renderCommitRights(ctx) + treeNames, treePaths := getParentTreeFields(form.TreePath) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + + ctx.Data["PageIsEdit"] = true + ctx.Data["PageHasPosted"] = true + ctx.Data["IsNewFile"] = isNewFile + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["TreePath"] = form.TreePath + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + ctx.Repo.BranchName + ctx.Data["FileContent"] = form.Content + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["MarkdownFileExts"] = strings.Join(setting.Markdown.FileExtensions, ",") + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["PreviewableFileModes"] = strings.Join(setting.Repository.Editor.PreviewableFileModes, ",") + ctx.Data["Editorconfig"] = GetEditorConfig(ctx, form.TreePath) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplEditFile) + return + } + + // Cannot commit to a an existing branch if user doesn't have rights + if branchName == ctx.Repo.BranchName && !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) + return + } + + // CommitSummary is optional in the web form, if empty, give it a default message based on add or update + // `message` will be both the summary and message combined + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + if isNewFile { + message = ctx.Tr("repo.editor.add", form.TreePath) + } else { + message = ctx.Tr("repo.editor.update", form.TreePath) + } + } + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + if _, err := repofiles.CreateOrUpdateRepoFile(ctx.Repo.Repository, ctx.User, &repofiles.UpdateRepoFileOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + FromTreePath: ctx.Repo.TreePath, + TreePath: form.TreePath, + Message: message, + Content: strings.ReplaceAll(form.Content, "\r", ""), + IsNewFile: isNewFile, + Signoff: form.Signoff, + }); err != nil { + // This is where we handle all the errors thrown by repofiles.CreateOrUpdateRepoFile + if git.IsErrNotExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form) + } else if models.IsErrLFSFileLocked(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(models.ErrLFSFileLocked).Path, err.(models.ErrLFSFileLocked).UserName), tplEditFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", form.TreePath), tplEditFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + if fileErr, ok := err.(models.ErrFilePathInvalid); ok { + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplEditFile, &form) + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplEditFile, &form) + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplEditFile, &form) + default: + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrRepoFileAlreadyExists(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplEditFile, &form) + } else if git.IsErrBranchNotExist(err) { + // For when a user adds/updates a file to a branch that no longer exists + if branchErr, ok := err.(git.ErrBranchNotExist); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplEditFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + ctx.Data["Err_NewBranchName"] = true + if branchErr, ok := err.(models.ErrBranchAlreadyExists); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplEditFile, &form) + } else if git.IsErrPushOutOfDate(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+util.PathEscapeSegments(form.NewBranchName)), tplEditFile, &form) + } else if git.IsErrPushRejected(err) { + errPushRej := err.(*git.ErrPushRejected) + if len(errPushRej.Message) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplEditFile, &form) + } else { + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(errPushRej.Message), + }) + if err != nil { + ctx.ServerError("editFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplEditFile, &form) + } + } else { + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.editor.fail_to_update_file", form.TreePath), + "Summary": ctx.Tr("repo.editor.fail_to_update_file_summary"), + "Details": utils.SanitizeFlashErrorString(err.Error()), + }) + if err != nil { + ctx.ServerError("editFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplEditFile, &form) + } + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(models.UnitTypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(form.TreePath)) + } +} + +// EditFilePost response for editing file +func EditFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditRepoFileForm) + editFilePost(ctx, *form, false) +} + +// NewFilePost response for creating file +func NewFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditRepoFileForm) + editFilePost(ctx, *form, true) +} + +// DiffPreviewPost render preview diff page +func DiffPreviewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditPreviewDiffForm) + treePath := cleanUploadFileName(ctx.Repo.TreePath) + if len(treePath) == 0 { + ctx.Error(http.StatusInternalServerError, "file name to diff is invalid") + return + } + + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTreeEntryByPath: "+err.Error()) + return + } else if entry.IsDir() { + ctx.Error(http.StatusUnprocessableEntity) + return + } + + diff, err := repofiles.GetDiffPreview(ctx.Repo.Repository, ctx.Repo.BranchName, treePath, form.Content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetDiffPreview: "+err.Error()) + return + } + + if diff.NumFiles == 0 { + ctx.PlainText(200, []byte(ctx.Tr("repo.editor.no_changes_to_show"))) + return + } + ctx.Data["File"] = diff.Files[0] + + ctx.HTML(http.StatusOK, tplEditDiffPreview) +} + +// DeleteFile render delete file page +func DeleteFile(ctx *context.Context) { + ctx.Data["PageIsDelete"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treePath := cleanUploadFileName(ctx.Repo.TreePath) + + if treePath != ctx.Repo.TreePath { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_delete", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + return + } + + ctx.Data["TreePath"] = treePath + canCommit := renderCommitRights(ctx) + + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + ctx.Data["last_commit"] = ctx.Repo.CommitID + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + + ctx.HTML(http.StatusOK, tplDeleteFile) +} + +// DeleteFilePost response for deleting file +func DeleteFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.DeleteRepoFileForm) + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + + ctx.Data["PageIsDelete"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["TreePath"] = ctx.Repo.TreePath + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplDeleteFile) + return + } + + if branchName == ctx.Repo.BranchName && !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplDeleteFile, &form) + return + } + + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + message = ctx.Tr("repo.editor.delete", ctx.Repo.TreePath) + } + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + if _, err := repofiles.DeleteRepoFile(ctx.Repo.Repository, ctx.User, &repofiles.DeleteRepoFileOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + TreePath: ctx.Repo.TreePath, + Message: message, + Signoff: form.Signoff, + }); err != nil { + // This is where we handle all the errors thrown by repofiles.DeleteRepoFile + if git.IsErrNotExist(err) || models.IsErrRepoFileDoesNotExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_deleting_no_longer_exists", ctx.Repo.TreePath), tplDeleteFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", ctx.Repo.TreePath), tplDeleteFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + if fileErr, ok := err.(models.ErrFilePathInvalid); ok { + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplDeleteFile, &form) + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplDeleteFile, &form) + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplDeleteFile, &form) + default: + ctx.ServerError("DeleteRepoFile", err) + } + } else { + ctx.ServerError("DeleteRepoFile", err) + } + } else if git.IsErrBranchNotExist(err) { + // For when a user deletes a file to a branch that no longer exists + if branchErr, ok := err.(git.ErrBranchNotExist); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplDeleteFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + if branchErr, ok := err.(models.ErrBranchAlreadyExists); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplDeleteFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrCommitIDDoesNotMatch(err) || git.IsErrPushOutOfDate(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_deleting", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplDeleteFile, &form) + } else if git.IsErrPushRejected(err) { + errPushRej := err.(*git.ErrPushRejected) + if len(errPushRej.Message) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplDeleteFile, &form) + } else { + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(errPushRej.Message), + }) + if err != nil { + ctx.ServerError("DeleteFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplDeleteFile, &form) + } + } else { + ctx.ServerError("DeleteRepoFile", err) + } + } + + ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", ctx.Repo.TreePath)) + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(models.UnitTypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + treePath := path.Dir(ctx.Repo.TreePath) + if treePath == "." { + treePath = "" // the file deleted was in the root, so we return the user to the root directory + } + if len(treePath) > 0 { + // Need to get the latest commit since it changed + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) + if err == nil && commit != nil { + // We have the comment, now find what directory we can return the user to + // (must have entries) + treePath = GetClosestParentWithFiles(treePath, commit) + } else { + treePath = "" // otherwise return them to the root of the repo + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(treePath)) + } +} + +// UploadFile render upload file page +func UploadFile(ctx *context.Context) { + ctx.Data["PageIsUpload"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["RequireSimpleMDE"] = true + upload.AddUploadContext(ctx, "repo") + canCommit := renderCommitRights(ctx) + treePath := cleanUploadFileName(ctx.Repo.TreePath) + if treePath != ctx.Repo.TreePath { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_upload", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + return + } + ctx.Repo.TreePath = treePath + + treeNames, treePaths := getParentTreeFields(ctx.Repo.TreePath) + if len(treeNames) == 0 { + // We must at least have one element for user to input. + treeNames = []string{""} + } + + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + + ctx.HTML(http.StatusOK, tplUploadFile) +} + +// UploadFilePost response for uploading file +func UploadFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UploadRepoFileForm) + ctx.Data["PageIsUpload"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["RequireSimpleMDE"] = true + upload.AddUploadContext(ctx, "repo") + canCommit := renderCommitRights(ctx) + + oldBranchName := ctx.Repo.BranchName + branchName := oldBranchName + + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + + form.TreePath = cleanUploadFileName(form.TreePath) + + treeNames, treePaths := getParentTreeFields(form.TreePath) + if len(treeNames) == 0 { + // We must at least have one element for user to input. + treeNames = []string{""} + } + + ctx.Data["TreePath"] = form.TreePath + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + branchName + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = branchName + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUploadFile) + return + } + + if oldBranchName != branchName { + if _, err := repo_module.GetBranch(ctx.Repo.Repository, branchName); err == nil { + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchName), tplUploadFile, &form) + return + } + } else if !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplUploadFile, &form) + return + } + + var newTreePath string + for _, part := range treeNames { + newTreePath = path.Join(newTreePath, part) + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(newTreePath) + if err != nil { + if git.IsErrNotExist(err) { + // Means there is no item with that name, so we're good + break + } + + ctx.ServerError("Repo.Commit.GetTreeEntryByPath", err) + return + } + + // User can only upload files to a directory. + if !entry.IsDir() { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", part), tplUploadFile, &form) + return + } + } + + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + message = ctx.Tr("repo.editor.upload_files_to_dir", form.TreePath) + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + if err := repofiles.UploadRepoFiles(ctx.Repo.Repository, ctx.User, &repofiles.UploadRepoFileOptions{ + LastCommitID: ctx.Repo.CommitID, + OldBranch: oldBranchName, + NewBranch: branchName, + TreePath: form.TreePath, + Message: message, + Files: form.Files, + Signoff: form.Signoff, + }); err != nil { + if models.IsErrLFSFileLocked(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(models.ErrLFSFileLocked).Path, err.(models.ErrLFSFileLocked).UserName), tplUploadFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", form.TreePath), tplUploadFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + fileErr := err.(models.ErrFilePathInvalid) + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplUploadFile, &form) + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplUploadFile, &form) + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplUploadFile, &form) + default: + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrRepoFileAlreadyExists(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplUploadFile, &form) + } else if git.IsErrBranchNotExist(err) { + branchErr := err.(git.ErrBranchNotExist) + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplUploadFile, &form) + } else if models.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + ctx.Data["Err_NewBranchName"] = true + branchErr := err.(models.ErrBranchAlreadyExists) + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplUploadFile, &form) + } else if git.IsErrPushOutOfDate(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+ctx.Repo.CommitID+"..."+util.PathEscapeSegments(form.NewBranchName)), tplUploadFile, &form) + } else if git.IsErrPushRejected(err) { + errPushRej := err.(*git.ErrPushRejected) + if len(errPushRej.Message) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplUploadFile, &form) + } else { + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(errPushRej.Message), + }) + if err != nil { + ctx.ServerError("UploadFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplUploadFile, &form) + } + } else { + // os.ErrNotExist - upload file missing in the intervening time?! + log.Error("Error during upload to repo: %-v to filepath: %s on %s from %s: %v", ctx.Repo.Repository, form.TreePath, oldBranchName, form.NewBranchName, err) + ctx.RenderWithErr(ctx.Tr("repo.editor.unable_to_upload_files", form.TreePath, err), tplUploadFile, &form) + } + return + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(models.UnitTypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(form.TreePath)) + } +} + +func cleanUploadFileName(name string) string { + // Rebase the filename + name = strings.Trim(path.Clean("/"+name), " /") + // Git disallows any filenames to have a .git directory in them. + for _, part := range strings.Split(name, "/") { + if strings.ToLower(part) == ".git" { + return "" + } + } + return name +} + +// UploadFileToServer upload file to server file dir not git +func UploadFileToServer(ctx *context.Context) { + file, header, err := ctx.Req.FormFile("file") + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + return + } + defer file.Close() + + buf := make([]byte, 1024) + n, _ := file.Read(buf) + if n > 0 { + buf = buf[:n] + } + + err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes) + if err != nil { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + + name := cleanUploadFileName(header.Filename) + if len(name) == 0 { + ctx.Error(http.StatusInternalServerError, "Upload file name is invalid") + return + } + + upload, err := models.NewUpload(name, buf, file) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("NewUpload: %v", err)) + return + } + + log.Trace("New file uploaded: %s", upload.UUID) + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": upload.UUID, + }) +} + +// RemoveUploadFileFromServer remove file from server file dir +func RemoveUploadFileFromServer(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RemoveUploadFileForm) + if len(form.File) == 0 { + ctx.Status(204) + return + } + + if err := models.DeleteUploadByUUID(form.File); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteUploadByUUID: %v", err)) + return + } + + log.Trace("Upload file removed: %s", form.File) + ctx.Status(204) +} + +// GetUniquePatchBranchName Gets a unique branch name for a new patch branch +// It will be in the form of <username>-patch-<num> where <num> is the first branch of this format +// that doesn't already exist. If we exceed 1000 tries or an error is thrown, we just return "" so the user has to +// type in the branch name themselves (will be an empty field) +func GetUniquePatchBranchName(ctx *context.Context) string { + prefix := ctx.User.LowerName + "-patch-" + for i := 1; i <= 1000; i++ { + branchName := fmt.Sprintf("%s%d", prefix, i) + if _, err := repo_module.GetBranch(ctx.Repo.Repository, branchName); err != nil { + if git.IsErrBranchNotExist(err) { + return branchName + } + log.Error("GetUniquePatchBranchName: %v", err) + return "" + } + } + return "" +} + +// GetClosestParentWithFiles Recursively gets the path of parent in a tree that has files (used when file in a tree is +// deleted). Returns "" for the root if no parents other than the root have files. If the given treePath isn't a +// SubTree or it has no entries, we go up one dir and see if we can return the user to that listing. +func GetClosestParentWithFiles(treePath string, commit *git.Commit) string { + if len(treePath) == 0 || treePath == "." { + return "" + } + // see if the tree has entries + if tree, err := commit.SubTree(treePath); err != nil { + // failed to get tree, going up a dir + return GetClosestParentWithFiles(path.Dir(treePath), commit) + } else if entries, err := tree.ListEntries(); err != nil || len(entries) == 0 { + // no files in this dir, going up a dir + return GetClosestParentWithFiles(path.Dir(treePath), commit) + } + return treePath +} diff --git a/routers/web/repo/editor_test.go b/routers/web/repo/editor_test.go new file mode 100644 index 0000000000..ec7aee1e77 --- /dev/null +++ b/routers/web/repo/editor_test.go @@ -0,0 +1,83 @@ +// 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 repo + +import ( + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestCleanUploadName(t *testing.T) { + models.PrepareTestEnv(t) + + var kases = map[string]string{ + ".git/refs/master": "", + "/root/abc": "root/abc", + "./../../abc": "abc", + "a/../.git": "", + "a/../../../abc": "abc", + "../../../acd": "acd", + "../../.git/abc": "", + "..\\..\\.git/abc": "..\\..\\.git/abc", + "..\\../.git/abc": "", + "..\\../.git": "", + "abc/../def": "def", + ".drone.yml": ".drone.yml", + ".abc/def/.drone.yml": ".abc/def/.drone.yml", + "..drone.yml.": "..drone.yml.", + "..a.dotty...name...": "..a.dotty...name...", + "..a.dotty../.folder../.name...": "..a.dotty../.folder../.name...", + } + for k, v := range kases { + assert.EqualValues(t, cleanUploadFileName(k), v) + } +} + +func TestGetUniquePatchBranchName(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + + expectedBranchName := "user2-patch-1" + branchName := GetUniquePatchBranchName(ctx) + assert.Equal(t, expectedBranchName, branchName) +} + +func TestGetClosestParentWithFiles(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + + repo := ctx.Repo.Repository + branch := repo.DefaultBranch + gitRepo, _ := git.OpenRepository(repo.RepoPath()) + defer gitRepo.Close() + commit, _ := gitRepo.GetBranchCommit(branch) + expectedTreePath := "" + + expectedTreePath = "" // Should return the root dir, empty string, since there are no subdirs in this repo + for _, deletedFile := range []string{ + "dir1/dir2/dir3/file.txt", + "file.txt", + } { + treePath := GetClosestParentWithFiles(deletedFile, commit) + assert.Equal(t, expectedTreePath, treePath) + } +} diff --git a/routers/web/repo/http.go b/routers/web/repo/http.go new file mode 100644 index 0000000000..30d382b8ef --- /dev/null +++ b/routers/web/repo/http.go @@ -0,0 +1,602 @@ +// 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 repo + +import ( + "bytes" + "compress/gzip" + gocontext "context" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + repo_service "code.gitea.io/gitea/services/repository" +) + +// httpBase implmentation git smart HTTP protocol +func httpBase(ctx *context.Context) (h *serviceHandler) { + if setting.Repository.DisableHTTPGit { + ctx.Resp.WriteHeader(http.StatusForbidden) + _, err := ctx.Resp.Write([]byte("Interacting with repositories by HTTP protocol is not allowed")) + if err != nil { + log.Error(err.Error()) + } + return + } + + if len(setting.Repository.AccessControlAllowOrigin) > 0 { + allowedOrigin := setting.Repository.AccessControlAllowOrigin + // Set CORS headers for browser-based git clients + ctx.Resp.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + ctx.Resp.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent") + + // Handle preflight OPTIONS request + if ctx.Req.Method == "OPTIONS" { + if allowedOrigin == "*" { + ctx.Status(http.StatusOK) + } else if allowedOrigin == "null" { + ctx.Status(http.StatusForbidden) + } else { + origin := ctx.Req.Header.Get("Origin") + if len(origin) > 0 && origin == allowedOrigin { + ctx.Status(http.StatusOK) + } else { + ctx.Status(http.StatusForbidden) + } + } + return + } + } + + username := ctx.Params(":username") + reponame := strings.TrimSuffix(ctx.Params(":reponame"), ".git") + + if ctx.Query("go-get") == "1" { + context.EarlyResponseForGoGetMeta(ctx) + return + } + + var isPull, receivePack bool + service := ctx.Query("service") + if service == "git-receive-pack" || + strings.HasSuffix(ctx.Req.URL.Path, "git-receive-pack") { + isPull = false + receivePack = true + } else if service == "git-upload-pack" || + strings.HasSuffix(ctx.Req.URL.Path, "git-upload-pack") { + isPull = true + } else if service == "git-upload-archive" || + strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") { + isPull = true + } else { + isPull = ctx.Req.Method == "GET" + } + + var accessMode models.AccessMode + if isPull { + accessMode = models.AccessModeRead + } else { + accessMode = models.AccessModeWrite + } + + isWiki := false + var unitType = models.UnitTypeCode + var wikiRepoName string + if strings.HasSuffix(reponame, ".wiki") { + isWiki = true + unitType = models.UnitTypeWiki + wikiRepoName = reponame + reponame = reponame[:len(reponame)-5] + } + + owner, err := models.GetUserByName(username) + if err != nil { + if models.IsErrUserNotExist(err) { + if redirectUserID, err := models.LookupUserRedirect(username); err == nil { + context.RedirectToUser(ctx, username, redirectUserID) + } else { + ctx.NotFound(fmt.Sprintf("User %s does not exist", username), nil) + } + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + if !owner.IsOrganization() && !owner.IsActive { + ctx.HandleText(http.StatusForbidden, "Repository cannot be accessed. You cannot push or open issues/pull-requests.") + return + } + + repoExist := true + repo, err := models.GetRepositoryByName(owner.ID, reponame) + if err != nil { + if models.IsErrRepoNotExist(err) { + if redirectRepoID, err := models.LookupRepoRedirect(owner.ID, reponame); err == nil { + context.RedirectToRepo(ctx, redirectRepoID) + return + } + repoExist = false + } else { + ctx.ServerError("GetRepositoryByName", err) + return + } + } + + // Don't allow pushing if the repo is archived + if repoExist && repo.IsArchived && !isPull { + ctx.HandleText(http.StatusForbidden, "This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.") + return + } + + // Only public pull don't need auth. + isPublicPull := repoExist && !repo.IsPrivate && isPull + var ( + askAuth = !isPublicPull || setting.Service.RequireSignInView + environ []string + ) + + // don't allow anonymous pulls if organization is not public + if isPublicPull { + if err := repo.GetOwner(); err != nil { + ctx.ServerError("GetOwner", err) + return + } + + askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic) + } + + // check access + if askAuth { + // rely on the results of Contexter + if !ctx.IsSigned { + // TODO: support digit auth - which would be Authorization header with digit + ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=\".\"") + ctx.Error(http.StatusUnauthorized) + return + } + + if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true { + _, err = models.GetTwoFactorByUID(ctx.User.ID) + if err == nil { + // TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented + ctx.HandleText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page") + return + } else if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("IsErrTwoFactorNotEnrolled", err) + return + } + } + + if !ctx.User.IsActive || ctx.User.ProhibitLogin { + ctx.HandleText(http.StatusForbidden, "Your account is disabled.") + return + } + + if repoExist { + perm, err := models.GetUserRepoPermission(repo, ctx.User) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + if !perm.CanAccess(accessMode, unitType) { + ctx.HandleText(http.StatusForbidden, "User permission denied") + return + } + + if !isPull && repo.IsMirror { + ctx.HandleText(http.StatusForbidden, "mirror repository is read-only") + return + } + } + + environ = []string{ + models.EnvRepoUsername + "=" + username, + models.EnvRepoName + "=" + reponame, + models.EnvPusherName + "=" + ctx.User.Name, + models.EnvPusherID + fmt.Sprintf("=%d", ctx.User.ID), + models.EnvIsDeployKey + "=false", + models.EnvAppURL + "=" + setting.AppURL, + } + + if !ctx.User.KeepEmailPrivate { + environ = append(environ, models.EnvPusherEmail+"="+ctx.User.Email) + } + + if isWiki { + environ = append(environ, models.EnvRepoIsWiki+"=true") + } else { + environ = append(environ, models.EnvRepoIsWiki+"=false") + } + } + + if !repoExist { + if !receivePack { + ctx.HandleText(http.StatusNotFound, "Repository not found") + return + } + + if isWiki { // you cannot send wiki operation before create the repository + ctx.HandleText(http.StatusNotFound, "Repository not found") + return + } + + if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg { + ctx.HandleText(http.StatusForbidden, "Push to create is not enabled for organizations.") + return + } + if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser { + ctx.HandleText(http.StatusForbidden, "Push to create is not enabled for users.") + return + } + + // Return dummy payload if GET receive-pack + if ctx.Req.Method == http.MethodGet { + dummyInfoRefs(ctx) + return + } + + repo, err = repo_service.PushCreateRepo(ctx.User, owner, reponame) + if err != nil { + log.Error("pushCreateRepo: %v", err) + ctx.Status(http.StatusNotFound) + return + } + } + + if isWiki { + // Ensure the wiki is enabled before we allow access to it + if _, err := repo.GetUnit(models.UnitTypeWiki); err != nil { + if models.IsErrUnitTypeNotExist(err) { + ctx.HandleText(http.StatusForbidden, "repository wiki is disabled") + return + } + log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err) + ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) + return + } + } + + environ = append(environ, models.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) + + w := ctx.Resp + r := ctx.Req + cfg := &serviceConfig{ + UploadPack: true, + ReceivePack: true, + Env: environ, + } + + r.URL.Path = strings.ToLower(r.URL.Path) // blue: In case some repo name has upper case name + + dir := models.RepoPath(username, reponame) + if isWiki { + dir = models.RepoPath(username, wikiRepoName) + } + + return &serviceHandler{cfg, w, r, dir, cfg.Env} +} + +var ( + infoRefsCache []byte + infoRefsOnce sync.Once +) + +func dummyInfoRefs(ctx *context.Context) { + infoRefsOnce.Do(func() { + tmpDir, err := ioutil.TempDir(os.TempDir(), "gitea-info-refs-cache") + if err != nil { + log.Error("Failed to create temp dir for git-receive-pack cache: %v", err) + return + } + + defer func() { + if err := util.RemoveAll(tmpDir); err != nil { + log.Error("RemoveAll: %v", err) + } + }() + + if err := git.InitRepository(tmpDir, true); err != nil { + log.Error("Failed to init bare repo for git-receive-pack cache: %v", err) + return + } + + refs, err := git.NewCommand("receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunInDirBytes(tmpDir) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + log.Debug("populating infoRefsCache: \n%s", string(refs)) + infoRefsCache = refs + }) + + ctx.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + ctx.Header().Set("Pragma", "no-cache") + ctx.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") + ctx.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement") + _, _ = ctx.Write(packetWrite("# service=git-receive-pack\n")) + _, _ = ctx.Write([]byte("0000")) + _, _ = ctx.Write(infoRefsCache) +} + +type serviceConfig struct { + UploadPack bool + ReceivePack bool + Env []string +} + +type serviceHandler struct { + cfg *serviceConfig + w http.ResponseWriter + r *http.Request + dir string + environ []string +} + +func (h *serviceHandler) setHeaderNoCache() { + h.w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + h.w.Header().Set("Pragma", "no-cache") + h.w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") +} + +func (h *serviceHandler) setHeaderCacheForever() { + now := time.Now().Unix() + expires := now + 31536000 + h.w.Header().Set("Date", fmt.Sprintf("%d", now)) + h.w.Header().Set("Expires", fmt.Sprintf("%d", expires)) + h.w.Header().Set("Cache-Control", "public, max-age=31536000") +} + +func (h *serviceHandler) sendFile(contentType, file string) { + reqFile := path.Join(h.dir, file) + + fi, err := os.Stat(reqFile) + if os.IsNotExist(err) { + h.w.WriteHeader(http.StatusNotFound) + return + } + + h.w.Header().Set("Content-Type", contentType) + h.w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + h.w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat)) + http.ServeFile(h.w, h.r, reqFile) +} + +// one or more key=value pairs separated by colons +var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) + +func getGitConfig(option, dir string) string { + out, err := git.NewCommand("config", option).RunInDir(dir) + if err != nil { + log.Error("%v - %s", err, out) + } + return out[0 : len(out)-1] +} + +func getConfigSetting(service, dir string) bool { + service = strings.ReplaceAll(service, "-", "") + setting := getGitConfig("http."+service, dir) + + if service == "uploadpack" { + return setting != "false" + } + + return setting == "true" +} + +func hasAccess(service string, h serviceHandler, checkContentType bool) bool { + if checkContentType { + if h.r.Header.Get("Content-Type") != fmt.Sprintf("application/x-git-%s-request", service) { + return false + } + } + + if !(service == "upload-pack" || service == "receive-pack") { + return false + } + if service == "receive-pack" { + return h.cfg.ReceivePack + } + if service == "upload-pack" { + return h.cfg.UploadPack + } + + return getConfigSetting(service, h.dir) +} + +func serviceRPC(h serviceHandler, service string) { + defer func() { + if err := h.r.Body.Close(); err != nil { + log.Error("serviceRPC: Close: %v", err) + } + + }() + + if !hasAccess(service, h, true) { + h.w.WriteHeader(http.StatusUnauthorized) + return + } + + h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) + + var err error + var reqBody = h.r.Body + + // Handle GZIP. + if h.r.Header.Get("Content-Encoding") == "gzip" { + reqBody, err = gzip.NewReader(reqBody) + if err != nil { + log.Error("Fail to create gzip reader: %v", err) + h.w.WriteHeader(http.StatusInternalServerError) + return + } + } + + // set this for allow pre-receive and post-receive execute + h.environ = append(h.environ, "SSH_ORIGINAL_COMMAND="+service) + + if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) + } + + ctx, cancel := gocontext.WithCancel(git.DefaultContext) + defer cancel() + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, git.GitExecutable, service, "--stateless-rpc", h.dir) + cmd.Dir = h.dir + cmd.Env = append(os.Environ(), h.environ...) + cmd.Stdout = h.w + cmd.Stdin = reqBody + cmd.Stderr = &stderr + + pid := process.GetManager().Add(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.dir), cancel) + defer process.GetManager().Remove(pid) + + if err := cmd.Run(); err != nil { + log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.dir, err, stderr.String()) + return + } +} + +// ServiceUploadPack implements Git Smart HTTP protocol +func ServiceUploadPack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(*h, "upload-pack") + } +} + +// ServiceReceivePack implements Git Smart HTTP protocol +func ServiceReceivePack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(*h, "receive-pack") + } +} + +func getServiceType(r *http.Request) string { + serviceType := r.FormValue("service") + if !strings.HasPrefix(serviceType, "git-") { + return "" + } + return strings.Replace(serviceType, "git-", "", 1) +} + +func updateServerInfo(dir string) []byte { + out, err := git.NewCommand("update-server-info").RunInDirBytes(dir) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(out))) + } + return out +} + +func packetWrite(str string) []byte { + s := strconv.FormatInt(int64(len(str)+4), 16) + if len(s)%4 != 0 { + s = strings.Repeat("0", 4-len(s)%4) + s + } + return []byte(s + str) +} + +// GetInfoRefs implements Git dumb HTTP +func GetInfoRefs(ctx *context.Context) { + h := httpBase(ctx) + if h == nil { + return + } + h.setHeaderNoCache() + if hasAccess(getServiceType(h.r), *h, false) { + service := getServiceType(h.r) + + if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) + } + h.environ = append(os.Environ(), h.environ...) + + refs, err := git.NewCommand(service, "--stateless-rpc", "--advertise-refs", ".").RunInDirTimeoutEnv(h.environ, -1, h.dir) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service)) + h.w.WriteHeader(http.StatusOK) + _, _ = h.w.Write(packetWrite("# service=git-" + service + "\n")) + _, _ = h.w.Write([]byte("0000")) + _, _ = h.w.Write(refs) + } else { + updateServerInfo(h.dir) + h.sendFile("text/plain; charset=utf-8", "info/refs") + } +} + +// GetTextFile implements Git dumb HTTP +func GetTextFile(p string) func(*context.Context) { + return func(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderNoCache() + file := ctx.Params("file") + if file != "" { + h.sendFile("text/plain", "objects/info/"+file) + } else { + h.sendFile("text/plain", p) + } + } + } +} + +// GetInfoPacks implements Git dumb HTTP +func GetInfoPacks(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("text/plain; charset=utf-8", "objects/info/packs") + } +} + +// GetLooseObject implements Git dumb HTTP +func GetLooseObject(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("application/x-git-loose-object", fmt.Sprintf("objects/%s/%s", + ctx.Params("head"), ctx.Params("hash"))) + } +} + +// GetPackFile implements Git dumb HTTP +func GetPackFile(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("application/x-git-packed-objects", "objects/pack/pack-"+ctx.Params("file")+".pack") + } +} + +// GetIdxFile implements Git dumb HTTP +func GetIdxFile(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") + } +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go new file mode 100644 index 0000000000..fd2877e706 --- /dev/null +++ b/routers/web/repo/issue.go @@ -0,0 +1,2599 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 repo + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + "net/http" + "path" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + "code.gitea.io/gitea/modules/git" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/upload" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + comment_service "code.gitea.io/gitea/services/comments" + "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + + "github.com/unknwon/com" +) + +const ( + tplAttachment base.TplName = "repo/issue/view_content/attachments" + + tplIssues base.TplName = "repo/issue/list" + tplIssueNew base.TplName = "repo/issue/new" + tplIssueChoose base.TplName = "repo/issue/choose" + tplIssueView base.TplName = "repo/issue/view" + + tplReactions base.TplName = "repo/issue/view_content/reactions" + + issueTemplateKey = "IssueTemplate" + issueTemplateTitleKey = "IssueTemplateTitle" +) + +var ( + // IssueTemplateCandidates issue templates + IssueTemplateCandidates = []string{ + "ISSUE_TEMPLATE.md", + "issue_template.md", + ".gitea/ISSUE_TEMPLATE.md", + ".gitea/issue_template.md", + ".github/ISSUE_TEMPLATE.md", + ".github/issue_template.md", + } +) + +// MustAllowUserComment checks to make sure if an issue is locked. +// If locked and user has permissions to write to the repository, +// then the comment is allowed, else it is blocked +func MustAllowUserComment(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.User.IsAdmin { + ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) + ctx.Redirect(issue.HTMLURL()) + return + } +} + +// MustEnableIssues check if repository enable internal issues +func MustEnableIssues(ctx *context.Context) { + if !ctx.Repo.CanRead(models.UnitTypeIssues) && + !ctx.Repo.CanRead(models.UnitTypeExternalTracker) { + ctx.NotFound("MustEnableIssues", nil) + return + } + + unit, err := ctx.Repo.Repository.GetUnit(models.UnitTypeExternalTracker) + if err == nil { + ctx.Redirect(unit.ExternalTrackerConfig().ExternalTrackerURL) + return + } +} + +// MustAllowPulls check if repository enable pull requests and user have right to do that +func MustAllowPulls(ctx *context.Context) { + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(models.UnitTypePullRequests) { + ctx.NotFound("MustAllowPulls", nil) + return + } + + // User can send pull request if owns a forked repository. + if ctx.IsSigned && ctx.User.HasForkedRepo(ctx.Repo.Repository.ID) { + ctx.Repo.PullRequest.Allowed = true + ctx.Repo.PullRequest.HeadInfo = ctx.User.Name + ":" + ctx.Repo.BranchName + } +} + +func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption util.OptionalBool) { + var err error + viewType := ctx.Query("type") + sortType := ctx.Query("sort") + types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested"} + if !util.IsStringInSlice(viewType, types, true) { + viewType = "all" + } + + var ( + assigneeID = ctx.QueryInt64("assignee") + posterID int64 + mentionedID int64 + reviewRequestedID int64 + forceEmpty bool + ) + + if ctx.IsSigned { + switch viewType { + case "created_by": + posterID = ctx.User.ID + case "mentioned": + mentionedID = ctx.User.ID + case "assigned": + assigneeID = ctx.User.ID + case "review_requested": + reviewRequestedID = ctx.User.ID + } + } + + repo := ctx.Repo.Repository + var labelIDs []int64 + selectLabels := ctx.Query("labels") + if len(selectLabels) > 0 && selectLabels != "0" { + labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) + if err != nil { + ctx.ServerError("StringsToInt64s", err) + return + } + } + + keyword := strings.Trim(ctx.Query("q"), " ") + if bytes.Contains([]byte(keyword), []byte{0x00}) { + keyword = "" + } + + var issueIDs []int64 + if len(keyword) > 0 { + issueIDs, err = issue_indexer.SearchIssuesByKeyword([]int64{repo.ID}, keyword) + if err != nil { + ctx.ServerError("issueIndexer.Search", err) + return + } + if len(issueIDs) == 0 { + forceEmpty = true + } + } + + var issueStats *models.IssueStats + if forceEmpty { + issueStats = &models.IssueStats{} + } else { + issueStats, err = models.GetIssueStats(&models.IssueStatsOptions{ + RepoID: repo.ID, + Labels: selectLabels, + MilestoneID: milestoneID, + AssigneeID: assigneeID, + MentionedID: mentionedID, + PosterID: posterID, + ReviewRequestedID: reviewRequestedID, + IsPull: isPullOption, + IssueIDs: issueIDs, + }) + if err != nil { + ctx.ServerError("GetIssueStats", err) + return + } + } + + isShowClosed := ctx.Query("state") == "closed" + // if open issues are zero and close don't, use closed as default + if len(ctx.Query("state")) == 0 && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 { + isShowClosed = true + } + + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + var total int + if !isShowClosed { + total = int(issueStats.OpenCount) + } else { + total = int(issueStats.ClosedCount) + } + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) + + var mileIDs []int64 + if milestoneID > 0 { + mileIDs = []int64{milestoneID} + } + + var issues []*models.Issue + if forceEmpty { + issues = []*models.Issue{} + } else { + issues, err = models.Issues(&models.IssuesOptions{ + ListOptions: models.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: setting.UI.IssuePagingNum, + }, + RepoIDs: []int64{repo.ID}, + AssigneeID: assigneeID, + PosterID: posterID, + MentionedID: mentionedID, + ReviewRequestedID: reviewRequestedID, + MilestoneIDs: mileIDs, + ProjectID: projectID, + IsClosed: util.OptionalBoolOf(isShowClosed), + IsPull: isPullOption, + LabelIDs: labelIDs, + SortType: sortType, + IssueIDs: issueIDs, + }) + if err != nil { + ctx.ServerError("Issues", err) + return + } + } + + var issueList = models.IssueList(issues) + approvalCounts, err := issueList.GetApprovalCounts() + if err != nil { + ctx.ServerError("ApprovalCounts", err) + return + } + + // Get posters. + for i := range issues { + // Check read status + if !ctx.IsSigned { + issues[i].IsRead = true + } else if err = issues[i].GetIsRead(ctx.User.ID); err != nil { + ctx.ServerError("GetIsRead", err) + return + } + } + + commitStatus, err := pull_service.GetIssuesLastCommitStatus(issues) + if err != nil { + ctx.ServerError("GetIssuesLastCommitStatus", err) + return + } + + ctx.Data["Issues"] = issues + ctx.Data["CommitStatus"] = commitStatus + + // Get assignees. + ctx.Data["Assignees"], err = repo.GetAssignees() + if err != nil { + ctx.ServerError("GetAssignees", err) + return + } + + handleTeamMentions(ctx) + if ctx.Written() { + return + } + + labels, err := models.GetLabelsByRepoID(repo.ID, "", models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + + if repo.Owner.IsOrganization() { + orgLabels, err := models.GetLabelsByOrgID(repo.Owner.ID, ctx.Query("sort"), models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + ctx.Data["OrgLabels"] = orgLabels + labels = append(labels, orgLabels...) + } + + for _, l := range labels { + l.LoadSelectedLabelsAfterClick(labelIDs) + } + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + + if ctx.QueryInt64("assignee") == 0 { + assigneeID = 0 // Reset ID to prevent unexpected selection of assignee. + } + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = + issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink) + + ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { + counts, ok := approvalCounts[issueID] + if !ok || len(counts) == 0 { + return 0 + } + reviewTyp := models.ReviewTypeApprove + if typ == "reject" { + reviewTyp = models.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = models.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + ctx.Data["IssueStats"] = issueStats + ctx.Data["SelLabelIDs"] = labelIDs + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["MilestoneID"] = milestoneID + ctx.Data["AssigneeID"] = assigneeID + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["Keyword"] = keyword + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "type", "ViewType") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + pager.AddParam(ctx, "labels", "SelectLabels") + pager.AddParam(ctx, "milestone", "MilestoneID") + pager.AddParam(ctx, "assignee", "AssigneeID") + ctx.Data["Page"] = pager +} + +// Issues render issues page +func Issues(ctx *context.Context) { + isPullList := ctx.Params(":type") == "pulls" + if isPullList { + MustAllowPulls(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.pulls") + ctx.Data["PageIsPullList"] = true + } else { + MustEnableIssues(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.issues") + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + } + + issues(ctx, ctx.QueryInt64("milestone"), ctx.QueryInt64("project"), util.OptionalBoolOf(isPullList)) + if ctx.Written() { + return + } + + var err error + // Get milestones + ctx.Data["Milestones"], err = models.GetMilestones(models.GetMilestonesOption{ + RepoID: ctx.Repo.Repository.ID, + State: api.StateType(ctx.Query("state")), + }) + if err != nil { + ctx.ServerError("GetAllRepoMilestones", err) + return + } + + ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList) + + ctx.HTML(http.StatusOK, tplIssues) +} + +// RetrieveRepoMilestonesAndAssignees find all the milestones and assignees of a repository +func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *models.Repository) { + var err error + ctx.Data["OpenMilestones"], err = models.GetMilestones(models.GetMilestonesOption{ + RepoID: repo.ID, + State: api.StateOpen, + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + ctx.Data["ClosedMilestones"], err = models.GetMilestones(models.GetMilestonesOption{ + RepoID: repo.ID, + State: api.StateClosed, + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + + ctx.Data["Assignees"], err = repo.GetAssignees() + if err != nil { + ctx.ServerError("GetAssignees", err) + return + } + + handleTeamMentions(ctx) + if ctx.Written() { + return + } +} + +func retrieveProjects(ctx *context.Context, repo *models.Repository) { + + var err error + + ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["ClosedProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: -1, + IsClosed: util.OptionalBoolTrue, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } +} + +// repoReviewerSelection items to bee shown +type repoReviewerSelection struct { + IsTeam bool + Team *models.Team + User *models.User + Review *models.Review + CanChange bool + Checked bool + ItemID int64 +} + +// RetrieveRepoReviewers find all reviewers of a repository +func RetrieveRepoReviewers(ctx *context.Context, repo *models.Repository, issue *models.Issue, canChooseReviewer bool) { + ctx.Data["CanChooseReviewer"] = canChooseReviewer + + originalAuthorReviews, err := models.GetReviewersFromOriginalAuthorsByIssueID(issue.ID) + if err != nil { + ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err) + return + } + ctx.Data["OriginalReviews"] = originalAuthorReviews + + reviews, err := models.GetReviewersByIssueID(issue.ID) + if err != nil { + ctx.ServerError("GetReviewersByIssueID", err) + return + } + + if len(reviews) == 0 && !canChooseReviewer { + return + } + + var ( + pullReviews []*repoReviewerSelection + reviewersResult []*repoReviewerSelection + teamReviewersResult []*repoReviewerSelection + teamReviewers []*models.Team + reviewers []*models.User + ) + + if canChooseReviewer { + posterID := issue.PosterID + if issue.OriginalAuthorID > 0 { + posterID = 0 + } + + reviewers, err = repo.GetReviewers(ctx.User.ID, posterID) + if err != nil { + ctx.ServerError("GetReviewers", err) + return + } + + teamReviewers, err = repo.GetReviewerTeams() + if err != nil { + ctx.ServerError("GetReviewerTeams", err) + return + } + + if len(reviewers) > 0 { + reviewersResult = make([]*repoReviewerSelection, 0, len(reviewers)) + } + + if len(teamReviewers) > 0 { + teamReviewersResult = make([]*repoReviewerSelection, 0, len(teamReviewers)) + } + } + + pullReviews = make([]*repoReviewerSelection, 0, len(reviews)) + + for _, review := range reviews { + tmp := &repoReviewerSelection{ + Checked: review.Type == models.ReviewTypeRequest, + Review: review, + ItemID: review.ReviewerID, + } + if review.ReviewerTeamID > 0 { + tmp.IsTeam = true + tmp.ItemID = -review.ReviewerTeamID + } + + if ctx.Repo.IsAdmin() { + // Admin can dismiss or re-request any review requests + tmp.CanChange = true + } else if ctx.User != nil && ctx.User.ID == review.ReviewerID && review.Type == models.ReviewTypeRequest { + // A user can refuse review requests + tmp.CanChange = true + } else if (canChooseReviewer || (ctx.User != nil && ctx.User.ID == issue.PosterID)) && review.Type != models.ReviewTypeRequest && + ctx.User.ID != review.ReviewerID { + // The poster of the PR, a manager, or official reviewers can re-request review from other reviewers + tmp.CanChange = true + } + + pullReviews = append(pullReviews, tmp) + + if canChooseReviewer { + if tmp.IsTeam { + teamReviewersResult = append(teamReviewersResult, tmp) + } else { + reviewersResult = append(reviewersResult, tmp) + } + } + } + + if len(pullReviews) > 0 { + // Drop all non-existing users and teams from the reviews + currentPullReviewers := make([]*repoReviewerSelection, 0, len(pullReviews)) + for _, item := range pullReviews { + if item.Review.ReviewerID > 0 { + if err = item.Review.LoadReviewer(); err != nil { + if models.IsErrUserNotExist(err) { + continue + } + ctx.ServerError("LoadReviewer", err) + return + } + item.User = item.Review.Reviewer + } else if item.Review.ReviewerTeamID > 0 { + if err = item.Review.LoadReviewerTeam(); err != nil { + if models.IsErrTeamNotExist(err) { + continue + } + ctx.ServerError("LoadReviewerTeam", err) + return + } + item.Team = item.Review.ReviewerTeam + } else { + continue + } + + currentPullReviewers = append(currentPullReviewers, item) + } + ctx.Data["PullReviewers"] = currentPullReviewers + } + + if canChooseReviewer && reviewersResult != nil { + preadded := len(reviewersResult) + for _, reviewer := range reviewers { + found := false + reviewAddLoop: + for _, tmp := range reviewersResult[:preadded] { + if tmp.ItemID == reviewer.ID { + tmp.User = reviewer + found = true + break reviewAddLoop + } + } + + if found { + continue + } + + reviewersResult = append(reviewersResult, &repoReviewerSelection{ + IsTeam: false, + CanChange: true, + User: reviewer, + ItemID: reviewer.ID, + }) + } + + ctx.Data["Reviewers"] = reviewersResult + } + + if canChooseReviewer && teamReviewersResult != nil { + preadded := len(teamReviewersResult) + for _, team := range teamReviewers { + found := false + teamReviewAddLoop: + for _, tmp := range teamReviewersResult[:preadded] { + if tmp.ItemID == -team.ID { + tmp.Team = team + found = true + break teamReviewAddLoop + } + } + + if found { + continue + } + + teamReviewersResult = append(teamReviewersResult, &repoReviewerSelection{ + IsTeam: true, + CanChange: true, + Team: team, + ItemID: -team.ID, + }) + } + + ctx.Data["TeamReviewers"] = teamReviewersResult + } +} + +// RetrieveRepoMetas find all the meta information of a repository +func RetrieveRepoMetas(ctx *context.Context, repo *models.Repository, isPull bool) []*models.Label { + if !ctx.Repo.CanWriteIssuesOrPulls(isPull) { + return nil + } + + labels, err := models.GetLabelsByRepoID(repo.ID, "", models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return nil + } + ctx.Data["Labels"] = labels + if repo.Owner.IsOrganization() { + orgLabels, err := models.GetLabelsByOrgID(repo.Owner.ID, ctx.Query("sort"), models.ListOptions{}) + if err != nil { + return nil + } + + ctx.Data["OrgLabels"] = orgLabels + labels = append(labels, orgLabels...) + } + + RetrieveRepoMilestonesAndAssignees(ctx, repo) + if ctx.Written() { + return nil + } + + retrieveProjects(ctx, repo) + if ctx.Written() { + return nil + } + + brs, _, err := ctx.Repo.GitRepo.GetBranches(0, 0) + if err != nil { + ctx.ServerError("GetBranches", err) + return nil + } + ctx.Data["Branches"] = brs + + // Contains true if the user can create issue dependencies + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.User, isPull) + + return labels +} + +func getFileContentFromDefaultBranch(ctx *context.Context, filename string) (string, bool) { + var bytes []byte + + if ctx.Repo.Commit == nil { + var err error + ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + return "", false + } + } + + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(filename) + if err != nil { + return "", false + } + if entry.Blob().Size() >= setting.UI.MaxDisplayFileSize { + return "", false + } + r, err := entry.Blob().DataAsync() + if err != nil { + return "", false + } + defer r.Close() + bytes, err = ioutil.ReadAll(r) + if err != nil { + return "", false + } + return string(bytes), true +} + +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs []string, possibleFiles []string) { + templateCandidates := make([]string, 0, len(possibleFiles)) + if ctx.Query("template") != "" { + for _, dirName := range possibleDirs { + templateCandidates = append(templateCandidates, path.Join(dirName, ctx.Query("template"))) + } + } + templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback + for _, filename := range templateCandidates { + templateContent, found := getFileContentFromDefaultBranch(ctx, filename) + if found { + var meta api.IssueTemplate + templateBody, err := markdown.ExtractMetadata(templateContent, &meta) + if err != nil { + log.Debug("could not extract metadata from %s [%s]: %v", filename, ctx.Repo.Repository.FullName(), err) + ctx.Data[ctxDataKey] = templateContent + return + } + ctx.Data[issueTemplateTitleKey] = meta.Title + ctx.Data[ctxDataKey] = templateBody + labelIDs := make([]string, 0, len(meta.Labels)) + if repoLabels, err := models.GetLabelsByRepoID(ctx.Repo.Repository.ID, "", models.ListOptions{}); err == nil { + ctx.Data["Labels"] = repoLabels + if ctx.Repo.Owner.IsOrganization() { + if orgLabels, err := models.GetLabelsByOrgID(ctx.Repo.Owner.ID, ctx.Query("sort"), models.ListOptions{}); err == nil { + ctx.Data["OrgLabels"] = orgLabels + repoLabels = append(repoLabels, orgLabels...) + } + } + + for _, metaLabel := range meta.Labels { + for _, repoLabel := range repoLabels { + if strings.EqualFold(repoLabel.Name, metaLabel) { + repoLabel.IsChecked = true + labelIDs = append(labelIDs, fmt.Sprintf("%d", repoLabel.ID)) + break + } + } + } + } + ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0 + ctx.Data["label_ids"] = strings.Join(labelIDs, ",") + return + } + } +} + +// NewIssue render creating issue page +func NewIssue(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + title := ctx.Query("title") + ctx.Data["TitleQuery"] = title + body := ctx.Query("body") + ctx.Data["BodyQuery"] = body + + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + milestoneID := ctx.QueryInt64("milestone") + if milestoneID > 0 { + milestone, err := models.GetMilestoneByID(milestoneID) + if err != nil { + log.Error("GetMilestoneByID: %d: %v", milestoneID, err) + } else { + ctx.Data["milestone_id"] = milestoneID + ctx.Data["Milestone"] = milestone + } + } + + projectID := ctx.QueryInt64("project") + if projectID > 0 { + project, err := models.GetProjectByID(projectID) + if err != nil { + log.Error("GetProjectByID: %d: %v", projectID, err) + } else if project.RepoID != ctx.Repo.Repository.ID { + log.Error("GetProjectByID: %d: %v", projectID, fmt.Errorf("project[%d] not in repo [%d]", project.ID, ctx.Repo.Repository.ID)) + } else { + ctx.Data["project_id"] = projectID + ctx.Data["Project"] = project + } + + } + + RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) + setTemplateIfExists(ctx, issueTemplateKey, context.IssueTemplateDirCandidates, IssueTemplateCandidates) + if ctx.Written() { + return + } + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypeIssues) + + ctx.HTML(http.StatusOK, tplIssueNew) +} + +// NewIssueChooseTemplate render creating issue from template page +func NewIssueChooseTemplate(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["milestone"] = ctx.QueryInt64("milestone") + + issueTemplates := ctx.IssueTemplatesFromDefaultBranch() + ctx.Data["NewIssueChooseTemplate"] = len(issueTemplates) > 0 + ctx.Data["IssueTemplates"] = issueTemplates + + ctx.HTML(http.StatusOK, tplIssueChoose) +} + +// ValidateRepoMetas check and returns repository's meta informations +func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) ([]int64, []int64, int64, int64) { + var ( + repo = ctx.Repo.Repository + err error + ) + + labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, isPull) + if ctx.Written() { + return nil, nil, 0, 0 + } + + var labelIDs []int64 + hasSelected := false + // Check labels. + if len(form.LabelIDs) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) + if err != nil { + return nil, nil, 0, 0 + } + labelIDMark := base.Int64sToMap(labelIDs) + + for i := range labels { + if labelIDMark[labels[i].ID] { + labels[i].IsChecked = true + hasSelected = true + } + } + } + + ctx.Data["Labels"] = labels + ctx.Data["HasSelectedLabel"] = hasSelected + ctx.Data["label_ids"] = form.LabelIDs + + // Check milestone. + milestoneID := form.MilestoneID + if milestoneID > 0 { + ctx.Data["Milestone"], err = repo.GetMilestoneByID(milestoneID) + if err != nil { + ctx.ServerError("GetMilestoneByID", err) + return nil, nil, 0, 0 + } + ctx.Data["milestone_id"] = milestoneID + } + + if form.ProjectID > 0 { + p, err := models.GetProjectByID(form.ProjectID) + if err != nil { + ctx.ServerError("GetProjectByID", err) + return nil, nil, 0, 0 + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return nil, nil, 0, 0 + } + + ctx.Data["Project"] = p + ctx.Data["project_id"] = form.ProjectID + } + + // Check assignees + var assigneeIDs []int64 + if len(form.AssigneeIDs) > 0 { + assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) + if err != nil { + return nil, nil, 0, 0 + } + + // Check if the passed assignees actually exists and is assignable + for _, aID := range assigneeIDs { + assignee, err := models.GetUserByID(aID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return nil, nil, 0, 0 + } + + valid, err := models.CanBeAssigned(assignee, repo, isPull) + if err != nil { + ctx.ServerError("CanBeAssigned", err) + return nil, nil, 0, 0 + } + + if !valid { + ctx.ServerError("canBeAssigned", models.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) + return nil, nil, 0, 0 + } + } + } + + // Keep the old assignee id thingy for compatibility reasons + if form.AssigneeID > 0 { + assigneeIDs = append(assigneeIDs, form.AssigneeID) + } + + return labelIDs, assigneeIDs, milestoneID, form.ProjectID +} + +// NewIssuePost response for creating new issue +func NewIssuePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateIssueForm) + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["ReadOnly"] = false + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + var ( + repo = ctx.Repo.Repository + attachments []string + ) + + labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, false) + if ctx.Written() { + return + } + + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplIssueNew) + return + } + + if util.IsEmptyString(form.Title) { + ctx.RenderWithErr(ctx.Tr("repo.issues.new.title_empty"), tplIssueNew, form) + return + } + + issue := &models.Issue{ + RepoID: repo.ID, + Title: form.Title, + PosterID: ctx.User.ID, + Poster: ctx.User, + MilestoneID: milestoneID, + Content: form.Content, + Ref: form.Ref, + } + + if err := issue_service.NewIssue(repo, issue, labelIDs, attachments, assigneeIDs); err != nil { + if models.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + return + } + ctx.ServerError("NewIssue", err) + return + } + + if projectID > 0 { + if err := models.ChangeProjectAssign(issue, ctx.User, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + + log.Trace("Issue created: %d/%d", repo.ID, issue.ID) + ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + fmt.Sprint(issue.Index)) +} + +// commentTag returns the CommentTag for a comment in/with the given repo, poster and issue +func commentTag(repo *models.Repository, poster *models.User, issue *models.Issue) (models.CommentTag, error) { + perm, err := models.GetUserRepoPermission(repo, poster) + if err != nil { + return models.CommentTagNone, err + } + if perm.IsOwner() { + if !poster.IsAdmin { + return models.CommentTagOwner, nil + } + + ok, err := models.IsUserRealRepoAdmin(repo, poster) + if err != nil { + return models.CommentTagNone, err + } + + if ok { + return models.CommentTagOwner, nil + } + + if ok, err = repo.IsCollaborator(poster.ID); ok && err == nil { + return models.CommentTagWriter, nil + } + + return models.CommentTagNone, err + } + + if perm.CanWrite(models.UnitTypeCode) { + return models.CommentTagWriter, nil + } + + return models.CommentTagNone, nil +} + +func getBranchData(ctx *context.Context, issue *models.Issue) { + ctx.Data["BaseBranch"] = nil + ctx.Data["HeadBranch"] = nil + ctx.Data["HeadUserName"] = nil + ctx.Data["BaseName"] = ctx.Repo.Repository.OwnerName + if issue.IsPull { + pull := issue.PullRequest + ctx.Data["BaseBranch"] = pull.BaseBranch + ctx.Data["HeadBranch"] = pull.HeadBranch + ctx.Data["HeadUserName"] = pull.MustHeadUserName() + } +} + +// ViewIssue render issue view page +func ViewIssue(ctx *context.Context) { + if ctx.Params(":type") == "issues" { + // If issue was requested we check if repo has external tracker and redirect + extIssueUnit, err := ctx.Repo.Repository.GetUnit(models.UnitTypeExternalTracker) + if err == nil && extIssueUnit != nil { + if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { + metas := ctx.Repo.Repository.ComposeMetas() + metas["index"] = ctx.Params(":index") + ctx.Redirect(com.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas)) + return + } + } else if err != nil && !models.IsErrUnitTypeNotExist(err) { + ctx.ServerError("GetUnit", err) + return + } + } + + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + + // Make sure type and URL matches. + if ctx.Params(":type") == "issues" && issue.IsPull { + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) + return + } else if ctx.Params(":type") == "pulls" && !issue.IsPull { + ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + fmt.Sprint(issue.Index)) + return + } + + if issue.IsPull { + MustAllowPulls(ctx) + if ctx.Written() { + return + } + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullConversation"] = true + } else { + MustEnableIssues(ctx) + if ctx.Written() { + return + } + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + } + + if issue.IsPull && !ctx.Repo.CanRead(models.UnitTypeIssues) { + ctx.Data["IssueType"] = "pulls" + } else if !issue.IsPull && !ctx.Repo.CanRead(models.UnitTypePullRequests) { + ctx.Data["IssueType"] = "issues" + } else { + ctx.Data["IssueType"] = "all" + } + + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + if err = issue.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + if err = filterXRefComments(ctx, issue); err != nil { + ctx.ServerError("filterXRefComments", err) + return + } + + ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title) + + iw := new(models.IssueWatch) + if ctx.User != nil { + iw.UserID = ctx.User.ID + iw.IssueID = issue.ID + iw.IsWatching, err = models.CheckIssueWatch(ctx.User, issue) + if err != nil { + ctx.ServerError("CheckIssueWatch", err) + return + } + } + ctx.Data["IssueWatch"] = iw + + issue.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, issue.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + repo := ctx.Repo.Repository + + // Get more information if it's a pull request. + if issue.IsPull { + if issue.PullRequest.HasMerged { + ctx.Data["DisableStatusChange"] = issue.PullRequest.HasMerged + PrepareMergedViewPullInfo(ctx, issue) + } else { + PrepareViewPullInfo(ctx, issue) + ctx.Data["DisableStatusChange"] = ctx.Data["IsPullRequestBroken"] == true && issue.IsClosed + } + if ctx.Written() { + return + } + } + + // Metas. + // Check labels. + labelIDMark := make(map[int64]bool) + for i := range issue.Labels { + labelIDMark[issue.Labels[i].ID] = true + } + labels, err := models.GetLabelsByRepoID(repo.ID, "", models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + ctx.Data["Labels"] = labels + + if repo.Owner.IsOrganization() { + orgLabels, err := models.GetLabelsByOrgID(repo.Owner.ID, ctx.Query("sort"), models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + ctx.Data["OrgLabels"] = orgLabels + + labels = append(labels, orgLabels...) + } + + hasSelected := false + for i := range labels { + if labelIDMark[labels[i].ID] { + labels[i].IsChecked = true + hasSelected = true + } + } + ctx.Data["HasSelectedLabel"] = hasSelected + + // Check milestone and assignee. + if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + RetrieveRepoMilestonesAndAssignees(ctx, repo) + retrieveProjects(ctx, repo) + + if ctx.Written() { + return + } + } + + if issue.IsPull { + canChooseReviewer := ctx.Repo.CanWrite(models.UnitTypePullRequests) + if !canChooseReviewer && ctx.User != nil && ctx.IsSigned { + canChooseReviewer, err = models.IsOfficialReviewer(issue, ctx.User) + if err != nil { + ctx.ServerError("IsOfficialReviewer", err) + return + } + } + + RetrieveRepoReviewers(ctx, repo, issue, canChooseReviewer) + if ctx.Written() { + return + } + } + + if ctx.IsSigned { + // Update issue-user. + if err = issue.ReadBy(ctx.User.ID); err != nil { + ctx.ServerError("ReadBy", err) + return + } + } + + var ( + tag models.CommentTag + ok bool + marked = make(map[int64]models.CommentTag) + comment *models.Comment + participants = make([]*models.User, 1, 10) + ) + if ctx.Repo.Repository.IsTimetrackerEnabled() { + if ctx.IsSigned { + // Deal with the stopwatch + ctx.Data["IsStopwatchRunning"] = models.StopwatchExists(ctx.User.ID, issue.ID) + if !ctx.Data["IsStopwatchRunning"].(bool) { + var exists bool + var sw *models.Stopwatch + if exists, sw, err = models.HasUserStopwatch(ctx.User.ID); err != nil { + ctx.ServerError("HasUserStopwatch", err) + return + } + ctx.Data["HasUserStopwatch"] = exists + if exists { + // Add warning if the user has already a stopwatch + var otherIssue *models.Issue + if otherIssue, err = models.GetIssueByID(sw.IssueID); err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + if err = otherIssue.LoadRepo(); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + // Add link to the issue of the already running stopwatch + ctx.Data["OtherStopwatchURL"] = otherIssue.HTMLURL() + } + } + ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(issue, ctx.User) + } else { + ctx.Data["CanUseTimetracker"] = false + } + if ctx.Data["WorkingUsers"], err = models.TotalTimes(models.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil { + ctx.ServerError("TotalTimes", err) + return + } + } + + // Check if the user can use the dependencies + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.User, issue.IsPull) + + // check if dependencies can be created across repositories + ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies + + if issue.ShowTag, err = commentTag(repo, issue.Poster, issue); err != nil { + ctx.ServerError("commentTag", err) + return + } + marked[issue.PosterID] = issue.ShowTag + + // Render comments and and fetch participants. + participants[0] = issue.Poster + for _, comment = range issue.Comments { + comment.Issue = issue + + if err := comment.LoadPoster(); err != nil { + ctx.ServerError("LoadPoster", err) + return + } + + if comment.Type == models.CommentTypeComment { + if err := comment.LoadAttachments(); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + // Check tag. + tag, ok = marked[comment.PosterID] + if ok { + comment.ShowTag = tag + continue + } + + comment.ShowTag, err = commentTag(repo, comment.Poster, issue) + if err != nil { + ctx.ServerError("commentTag", err) + return + } + marked[comment.PosterID] = comment.ShowTag + participants = addParticipant(comment.Poster, participants) + } else if comment.Type == models.CommentTypeLabel { + if err = comment.LoadLabel(); err != nil { + ctx.ServerError("LoadLabel", err) + return + } + } else if comment.Type == models.CommentTypeMilestone { + if err = comment.LoadMilestone(); err != nil { + ctx.ServerError("LoadMilestone", err) + return + } + ghostMilestone := &models.Milestone{ + ID: -1, + Name: ctx.Tr("repo.issues.deleted_milestone"), + } + if comment.OldMilestoneID > 0 && comment.OldMilestone == nil { + comment.OldMilestone = ghostMilestone + } + if comment.MilestoneID > 0 && comment.Milestone == nil { + comment.Milestone = ghostMilestone + } + } else if comment.Type == models.CommentTypeProject { + + if err = comment.LoadProject(); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + ghostProject := &models.Project{ + ID: -1, + Title: ctx.Tr("repo.issues.deleted_project"), + } + + if comment.OldProjectID > 0 && comment.OldProject == nil { + comment.OldProject = ghostProject + } + + if comment.ProjectID > 0 && comment.Project == nil { + comment.Project = ghostProject + } + + } else if comment.Type == models.CommentTypeAssignees || comment.Type == models.CommentTypeReviewRequest { + if err = comment.LoadAssigneeUserAndTeam(); err != nil { + ctx.ServerError("LoadAssigneeUserAndTeam", err) + return + } + } else if comment.Type == models.CommentTypeRemoveDependency || comment.Type == models.CommentTypeAddDependency { + if err = comment.LoadDepIssueDetails(); err != nil { + if !models.IsErrIssueNotExist(err) { + ctx.ServerError("LoadDepIssueDetails", err) + return + } + } + } else if comment.Type == models.CommentTypeCode || comment.Type == models.CommentTypeReview || comment.Type == models.CommentTypeDismissReview { + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + if err = comment.LoadReview(); err != nil && !models.IsErrReviewNotExist(err) { + ctx.ServerError("LoadReview", err) + return + } + participants = addParticipant(comment.Poster, participants) + if comment.Review == nil { + continue + } + if err = comment.Review.LoadAttributes(); err != nil { + if !models.IsErrUserNotExist(err) { + ctx.ServerError("Review.LoadAttributes", err) + return + } + comment.Review.Reviewer = models.NewGhostUser() + } + if err = comment.Review.LoadCodeComments(); err != nil { + ctx.ServerError("Review.LoadCodeComments", err) + return + } + for _, codeComments := range comment.Review.CodeComments { + for _, lineComments := range codeComments { + for _, c := range lineComments { + // Check tag. + tag, ok = marked[c.PosterID] + if ok { + c.ShowTag = tag + continue + } + + c.ShowTag, err = commentTag(repo, c.Poster, issue) + if err != nil { + ctx.ServerError("commentTag", err) + return + } + marked[c.PosterID] = c.ShowTag + participants = addParticipant(c.Poster, participants) + } + } + } + if err = comment.LoadResolveDoer(); err != nil { + ctx.ServerError("LoadResolveDoer", err) + return + } + } else if comment.Type == models.CommentTypePullPush { + participants = addParticipant(comment.Poster, participants) + if err = comment.LoadPushCommits(); err != nil { + ctx.ServerError("LoadPushCommits", err) + return + } + } else if comment.Type == models.CommentTypeAddTimeManual || + comment.Type == models.CommentTypeStopTracking { + // drop error since times could be pruned from DB.. + _ = comment.LoadTime() + } + } + + // Combine multiple label assignments into a single comment + combineLabelComments(issue) + + getBranchData(ctx, issue) + if issue.IsPull { + pull := issue.PullRequest + pull.Issue = issue + canDelete := false + ctx.Data["AllowMerge"] = false + + if ctx.IsSigned { + if err := pull.LoadHeadRepo(); err != nil { + log.Error("LoadHeadRepo: %v", err) + } else if pull.HeadRepo != nil && pull.HeadBranch != pull.HeadRepo.DefaultBranch { + perm, err := models.GetUserRepoPermission(pull.HeadRepo, ctx.User) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if perm.CanWrite(models.UnitTypeCode) { + // Check if branch is not protected + if protected, err := pull.HeadRepo.IsProtectedBranch(pull.HeadBranch, ctx.User); err != nil { + log.Error("IsProtectedBranch: %v", err) + } else if !protected { + canDelete = true + ctx.Data["DeleteBranchLink"] = ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index) + "/cleanup" + } + } + } + + if err := pull.LoadBaseRepo(); err != nil { + log.Error("LoadBaseRepo: %v", err) + } + perm, err := models.GetUserRepoPermission(pull.BaseRepo, ctx.User) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + ctx.Data["AllowMerge"], err = pull_service.IsUserAllowedToMerge(pull, perm, ctx.User) + if err != nil { + ctx.ServerError("IsUserAllowedToMerge", err) + return + } + + if ctx.Data["CanMarkConversation"], err = models.CanMarkConversation(issue, ctx.User); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + } + + prUnit, err := repo.GetUnit(models.UnitTypePullRequests) + if err != nil { + ctx.ServerError("GetUnit", err) + return + } + prConfig := prUnit.PullRequestsConfig() + + // Check correct values and select default + if ms, ok := ctx.Data["MergeStyle"].(models.MergeStyle); !ok || + !prConfig.IsMergeStyleAllowed(ms) { + defaultMergeStyle := prConfig.GetDefaultMergeStyle() + if prConfig.IsMergeStyleAllowed(defaultMergeStyle) && !ok { + ctx.Data["MergeStyle"] = defaultMergeStyle + } else if prConfig.AllowMerge { + ctx.Data["MergeStyle"] = models.MergeStyleMerge + } else if prConfig.AllowRebase { + ctx.Data["MergeStyle"] = models.MergeStyleRebase + } else if prConfig.AllowRebaseMerge { + ctx.Data["MergeStyle"] = models.MergeStyleRebaseMerge + } else if prConfig.AllowSquash { + ctx.Data["MergeStyle"] = models.MergeStyleSquash + } else if prConfig.AllowManualMerge { + ctx.Data["MergeStyle"] = models.MergeStyleManuallyMerged + } else { + ctx.Data["MergeStyle"] = "" + } + } + if err = pull.LoadProtectedBranch(); err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return + } + if pull.ProtectedBranch != nil { + cnt := pull.ProtectedBranch.GetGrantedApprovalsCount(pull) + ctx.Data["IsBlockedByApprovals"] = !pull.ProtectedBranch.HasEnoughApprovals(pull) + ctx.Data["IsBlockedByRejection"] = pull.ProtectedBranch.MergeBlockedByRejectedReview(pull) + ctx.Data["IsBlockedByOfficialReviewRequests"] = pull.ProtectedBranch.MergeBlockedByOfficialReviewRequests(pull) + ctx.Data["IsBlockedByOutdatedBranch"] = pull.ProtectedBranch.MergeBlockedByOutdatedBranch(pull) + ctx.Data["GrantedApprovals"] = cnt + ctx.Data["RequireSigned"] = pull.ProtectedBranch.RequireSignedCommits + ctx.Data["ChangedProtectedFiles"] = pull.ChangedProtectedFiles + ctx.Data["IsBlockedByChangedProtectedFiles"] = len(pull.ChangedProtectedFiles) != 0 + ctx.Data["ChangedProtectedFilesNum"] = len(pull.ChangedProtectedFiles) + } + ctx.Data["WillSign"] = false + if ctx.User != nil { + sign, key, _, err := pull.SignMerge(ctx.User, pull.BaseRepo.RepoPath(), pull.BaseBranch, pull.GetGitRefName()) + ctx.Data["WillSign"] = sign + ctx.Data["SigningKey"] = key + if err != nil { + if models.IsErrWontSign(err) { + ctx.Data["WontSignReason"] = err.(*models.ErrWontSign).Reason + } else { + ctx.Data["WontSignReason"] = "error" + log.Error("Error whilst checking if could sign pr %d in repo %s. Error: %v", pull.ID, pull.BaseRepo.FullName(), err) + } + } + } else { + ctx.Data["WontSignReason"] = "not_signed_in" + } + ctx.Data["IsPullBranchDeletable"] = canDelete && + pull.HeadRepo != nil && + git.IsBranchExist(pull.HeadRepo.RepoPath(), pull.HeadBranch) && + (!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"]) + + stillCanManualMerge := func() bool { + if pull.HasMerged || issue.IsClosed || !ctx.IsSigned { + return false + } + if pull.CanAutoMerge() || pull.IsWorkInProgress() || pull.IsChecking() { + return false + } + if (ctx.User.IsAdmin || ctx.Repo.IsAdmin()) && prConfig.AllowManualMerge { + return true + } + + return false + } + + ctx.Data["StillCanManualMerge"] = stillCanManualMerge() + } + + // Get Dependencies + ctx.Data["BlockedByDependencies"], err = issue.BlockedByDependencies() + if err != nil { + ctx.ServerError("BlockedByDependencies", err) + return + } + ctx.Data["BlockingDependencies"], err = issue.BlockingDependencies() + if err != nil { + ctx.ServerError("BlockingDependencies", err) + return + } + + ctx.Data["Participants"] = participants + ctx.Data["NumParticipants"] = len(participants) + ctx.Data["Issue"] = issue + ctx.Data["ReadOnly"] = false + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + ctx.Data["Link"].(string) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypeProjects) + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.User.IsAdmin) + ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons + ctx.Data["RefEndName"] = git.RefEndName(issue.Ref) + ctx.HTML(http.StatusOK, tplIssueView) +} + +// GetActionIssue will return the issue which is used in the context. +func GetActionIssue(ctx *context.Context) *models.Issue { + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err) + return nil + } + issue.Repo = ctx.Repo.Repository + checkIssueRights(ctx, issue) + if ctx.Written() { + return nil + } + if err = issue.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", nil) + return nil + } + return issue +} + +func checkIssueRights(ctx *context.Context, issue *models.Issue) { + if issue.IsPull && !ctx.Repo.CanRead(models.UnitTypePullRequests) || + !issue.IsPull && !ctx.Repo.CanRead(models.UnitTypeIssues) { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + } +} + +func getActionIssues(ctx *context.Context) []*models.Issue { + commaSeparatedIssueIDs := ctx.Query("issue_ids") + if len(commaSeparatedIssueIDs) == 0 { + return nil + } + issueIDs := make([]int64, 0, 10) + for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") { + issueID, err := strconv.ParseInt(stringIssueID, 10, 64) + if err != nil { + ctx.ServerError("ParseInt", err) + return nil + } + issueIDs = append(issueIDs, issueID) + } + issues, err := models.GetIssuesByIDs(issueIDs) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return nil + } + // Check access rights for all issues + issueUnitEnabled := ctx.Repo.CanRead(models.UnitTypeIssues) + prUnitEnabled := ctx.Repo.CanRead(models.UnitTypePullRequests) + for _, issue := range issues { + if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + return nil + } + if err = issue.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return issues +} + +// UpdateIssueTitle change issue's title +func UpdateIssueTitle(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.User.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + title := ctx.QueryTrim("title") + if len(title) == 0 { + ctx.Error(http.StatusNoContent) + return + } + + if err := issue_service.ChangeTitle(issue, ctx.User, title); err != nil { + ctx.ServerError("ChangeTitle", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "title": issue.Title, + }) +} + +// UpdateIssueRef change issue's ref (branch) +func UpdateIssueRef(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.User.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) || issue.IsPull { + ctx.Error(http.StatusForbidden) + return + } + + ref := ctx.QueryTrim("ref") + + if err := issue_service.ChangeIssueRef(issue, ctx.User, ref); err != nil { + ctx.ServerError("ChangeRef", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ref": ref, + }) +} + +// UpdateIssueContent change issue's content +func UpdateIssueContent(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.User.ID != issue.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + content := ctx.Query("content") + if err := issue_service.ChangeContent(issue, ctx.User, content); err != nil { + ctx.ServerError("ChangeContent", err) + return + } + + files := ctx.QueryStrings("files[]") + if err := updateAttachments(issue, files); err != nil { + ctx.ServerError("UpdateAttachments", err) + return + } + + content, err := markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Query("context"), + Metas: ctx.Repo.Repository.ComposeMetas(), + }, issue.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "content": content, + "attachments": attachmentsHTML(ctx, issue.Attachments, issue.Content), + }) +} + +// UpdateIssueMilestone change issue's milestone +func UpdateIssueMilestone(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + milestoneID := ctx.QueryInt64("id") + for _, issue := range issues { + oldMilestoneID := issue.MilestoneID + if oldMilestoneID == milestoneID { + continue + } + issue.MilestoneID = milestoneID + if err := issue_service.ChangeMilestoneAssign(issue, ctx.User, oldMilestoneID); err != nil { + ctx.ServerError("ChangeMilestoneAssign", err) + return + } + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// UpdateIssueAssignee change issue's or pull's assignee +func UpdateIssueAssignee(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + assigneeID := ctx.QueryInt64("id") + action := ctx.Query("action") + + for _, issue := range issues { + switch action { + case "clear": + if err := issue_service.DeleteNotPassedAssignee(issue, ctx.User, []*models.User{}); err != nil { + ctx.ServerError("ClearAssignees", err) + return + } + default: + assignee, err := models.GetUserByID(assigneeID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + valid, err := models.CanBeAssigned(assignee, issue.Repo, issue.IsPull) + if err != nil { + ctx.ServerError("canBeAssigned", err) + return + } + if !valid { + ctx.ServerError("canBeAssigned", models.ErrUserDoesNotHaveAccessToRepo{UserID: assigneeID, RepoName: issue.Repo.Name}) + return + } + + _, _, err = issue_service.ToggleAssignee(issue, ctx.User, assigneeID) + if err != nil { + ctx.ServerError("ToggleAssignee", err) + return + } + } + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// UpdatePullReviewRequest add or remove review request +func UpdatePullReviewRequest(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + reviewID := ctx.QueryInt64("id") + action := ctx.Query("action") + + // TODO: Not support 'clear' now + if action != "attach" && action != "detach" { + ctx.Status(403) + return + } + + for _, issue := range issues { + if err := issue.LoadRepo(); err != nil { + ctx.ServerError("issue.LoadRepo", err) + return + } + + if !issue.IsPull { + log.Warn( + "UpdatePullReviewRequest: refusing to add review request for non-PR issue %-v#%d", + issue.Repo, issue.Index, + ) + ctx.Status(403) + return + } + if reviewID < 0 { + // negative reviewIDs represent team requests + if err := issue.Repo.GetOwner(); err != nil { + ctx.ServerError("issue.Repo.GetOwner", err) + return + } + + if !issue.Repo.Owner.IsOrganization() { + log.Warn( + "UpdatePullReviewRequest: refusing to add team review request for %s#%d owned by non organization UID[%d]", + issue.Repo.FullName(), issue.Index, issue.Repo.ID, + ) + ctx.Status(403) + return + } + + team, err := models.GetTeamByID(-reviewID) + if err != nil { + ctx.ServerError("models.GetTeamByID", err) + return + } + + if team.OrgID != issue.Repo.OwnerID { + log.Warn( + "UpdatePullReviewRequest: refusing to add team review request for UID[%d] team %s to %s#%d owned by UID[%d]", + team.OrgID, team.Name, issue.Repo.FullName(), issue.Index, issue.Repo.ID) + ctx.Status(403) + return + } + + err = issue_service.IsValidTeamReviewRequest(team, ctx.User, action == "attach", issue) + if err != nil { + if models.IsErrNotValidReviewRequest(err) { + log.Warn( + "UpdatePullReviewRequest: refusing to add invalid team review request for UID[%d] team %s to %s#%d owned by UID[%d]: Error: %v", + team.OrgID, team.Name, issue.Repo.FullName(), issue.Index, issue.Repo.ID, + err, + ) + ctx.Status(403) + return + } + ctx.ServerError("IsValidTeamReviewRequest", err) + return + } + + _, err = issue_service.TeamReviewRequest(issue, ctx.User, team, action == "attach") + if err != nil { + ctx.ServerError("TeamReviewRequest", err) + return + } + continue + } + + reviewer, err := models.GetUserByID(reviewID) + if err != nil { + if models.IsErrUserNotExist(err) { + log.Warn( + "UpdatePullReviewRequest: requested reviewer [%d] for %-v to %-v#%d is not exist: Error: %v", + reviewID, issue.Repo, issue.Index, + err, + ) + ctx.Status(403) + return + } + ctx.ServerError("GetUserByID", err) + return + } + + err = issue_service.IsValidReviewRequest(reviewer, ctx.User, action == "attach", issue, nil) + if err != nil { + if models.IsErrNotValidReviewRequest(err) { + log.Warn( + "UpdatePullReviewRequest: refusing to add invalid review request for %-v to %-v#%d: Error: %v", + reviewer, issue.Repo, issue.Index, + err, + ) + ctx.Status(403) + return + } + ctx.ServerError("isValidReviewRequest", err) + return + } + + _, err = issue_service.ReviewRequest(issue, ctx.User, reviewer, action == "attach") + if err != nil { + ctx.ServerError("ReviewRequest", err) + return + } + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// UpdateIssueStatus change issue's status +func UpdateIssueStatus(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + var isClosed bool + switch action := ctx.Query("action"); action { + case "open": + isClosed = false + case "close": + isClosed = true + default: + log.Warn("Unrecognized action: %s", action) + } + + if _, err := models.IssueList(issues).LoadRepositories(); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + for _, issue := range issues { + if issue.IsClosed != isClosed { + if err := issue_service.ChangeStatus(issue, ctx.User, isClosed); err != nil { + if models.IsErrDependenciesLeft(err) { + ctx.JSON(http.StatusPreconditionFailed, map[string]interface{}{ + "error": "cannot close this issue because it still has open dependencies", + }) + return + } + ctx.ServerError("ChangeStatus", err) + return + } + } + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// NewComment create a comment for issue +func NewComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateCommentForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.User.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.User, + log.NewColoredIDValue(issue.PosterID), + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.User.IsAdmin { + ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) + ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) + return + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(issue.HTMLURL()) + return + } + + var comment *models.Comment + defer func() { + // Check if issue admin/poster changes the status of issue. + if (ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) || (ctx.IsSigned && issue.IsPoster(ctx.User.ID))) && + (form.Status == "reopen" || form.Status == "close") && + !(issue.IsPull && issue.PullRequest.HasMerged) { + + // Duplication and conflict check should apply to reopen pull request. + var pr *models.PullRequest + + if form.Status == "reopen" && issue.IsPull { + pull := issue.PullRequest + var err error + pr, err = models.GetUnmergedPullRequest(pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch) + if err != nil { + if !models.IsErrPullRequestNotExist(err) { + ctx.ServerError("GetUnmergedPullRequest", err) + return + } + } + + // Regenerate patch and test conflict. + if pr == nil { + pull_service.AddToTaskQueue(issue.PullRequest) + } + } + + if pr != nil { + ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index)) + } else { + isClosed := form.Status == "close" + if err := issue_service.ChangeStatus(issue, ctx.User, isClosed); err != nil { + log.Error("ChangeStatus: %v", err) + + if models.IsErrDependenciesLeft(err) { + if issue.IsPull { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther) + } else { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked")) + ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther) + } + return + } + } else { + if err := stopTimerIfAvailable(ctx.User, issue); err != nil { + ctx.ServerError("CreateOrStopIssueStopwatch", err) + return + } + + log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) + } + } + } + + // Redirect to comment hashtag if there is any actual content. + typeName := "issues" + if issue.IsPull { + typeName = "pulls" + } + if comment != nil { + ctx.Redirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag())) + } else { + ctx.Redirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) + } + }() + + // Fix #321: Allow empty comments, as long as we have attachments. + if len(form.Content) == 0 && len(attachments) == 0 { + return + } + + comment, err := comment_service.CreateIssueComment(ctx.User, ctx.Repo.Repository, issue, form.Content, attachments) + if err != nil { + ctx.ServerError("CreateIssueComment", err) + return + } + + log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) +} + +// UpdateCommentContent change comment of issue's content +func UpdateCommentContent(ctx *context.Context) { + comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(); err != nil { + ctx.NotFoundOrServerError("LoadIssue", models.IsErrIssueNotExist, err) + return + } + + if comment.Type == models.CommentTypeComment { + if err := comment.LoadAttachments(); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + } + + if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } else if comment.Type != models.CommentTypeComment && comment.Type != models.CommentTypeCode { + ctx.Error(http.StatusNoContent) + return + } + + oldContent := comment.Content + comment.Content = ctx.Query("content") + if len(comment.Content) == 0 { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "content": "", + }) + return + } + if err = comment_service.UpdateComment(comment, ctx.User, oldContent); err != nil { + ctx.ServerError("UpdateComment", err) + return + } + + files := ctx.QueryStrings("files[]") + if err := updateAttachments(comment, files); err != nil { + ctx.ServerError("UpdateAttachments", err) + return + } + + content, err := markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Query("context"), + Metas: ctx.Repo.Repository.ComposeMetas(), + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "content": content, + "attachments": attachmentsHTML(ctx, comment.Attachments, comment.Content), + }) +} + +// DeleteComment delete comment of issue +func DeleteComment(ctx *context.Context) { + comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(); err != nil { + ctx.NotFoundOrServerError("LoadIssue", models.IsErrIssueNotExist, err) + return + } + + if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } else if comment.Type != models.CommentTypeComment && comment.Type != models.CommentTypeCode { + ctx.Error(http.StatusNoContent) + return + } + + if err = comment_service.DeleteComment(ctx.User, comment); err != nil { + ctx.ServerError("DeleteCommentByID", err) + return + } + + ctx.Status(200) +} + +// ChangeIssueReaction create a reaction for issue +func ChangeIssueReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.User.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.User, + log.NewColoredIDValue(issue.PosterID), + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if ctx.HasError() { + ctx.ServerError("ChangeIssueReaction", errors.New(ctx.GetErrMsg())) + return + } + + switch ctx.Params(":action") { + case "react": + reaction, err := models.CreateIssueReaction(ctx.User, issue, form.Content) + if err != nil { + if models.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } + log.Info("CreateIssueReaction: %s", err) + break + } + // Reload new reactions + issue.Reactions = nil + if err = issue.LoadAttributes(); err != nil { + log.Info("issue.LoadAttributes: %s", err) + break + } + + log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) + case "unreact": + if err := models.DeleteIssueReaction(ctx.User, issue, form.Content); err != nil { + ctx.ServerError("DeleteIssueReaction", err) + return + } + + // Reload new reactions + issue.Reactions = nil + if err := issue.LoadAttributes(); err != nil { + log.Info("issue.LoadAttributes: %s", err) + break + } + + log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID) + default: + ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil) + return + } + + if len(issue.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "empty": true, + "html": "", + }) + return + } + + html, err := ctx.HTMLString(string(tplReactions), map[string]interface{}{ + "ctx": ctx.Data, + "ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index), + "Reactions": issue.Reactions.GroupByType(), + }) + if err != nil { + ctx.ServerError("ChangeIssueReaction.HTMLString", err) + return + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "html": html, + }) +} + +// ChangeCommentReaction create a reaction for comment +func ChangeCommentReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(); err != nil { + ctx.NotFoundOrServerError("LoadIssue", models.IsErrIssueNotExist, err) + return + } + + if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if comment.Issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.User, + log.NewColoredIDValue(comment.Issue.PosterID), + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } else if comment.Type != models.CommentTypeComment && comment.Type != models.CommentTypeCode { + ctx.Error(http.StatusNoContent) + return + } + + switch ctx.Params(":action") { + case "react": + reaction, err := models.CreateCommentReaction(ctx.User, comment.Issue, comment, form.Content) + if err != nil { + if models.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } + log.Info("CreateCommentReaction: %s", err) + break + } + // Reload new reactions + comment.Reactions = nil + if err = comment.LoadReactions(ctx.Repo.Repository); err != nil { + log.Info("comment.LoadReactions: %s", err) + break + } + + log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID, reaction.ID) + case "unreact": + if err := models.DeleteCommentReaction(ctx.User, comment.Issue, comment, form.Content); err != nil { + ctx.ServerError("DeleteCommentReaction", err) + return + } + + // Reload new reactions + comment.Reactions = nil + if err = comment.LoadReactions(ctx.Repo.Repository); err != nil { + log.Info("comment.LoadReactions: %s", err) + break + } + + log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID) + default: + ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil) + return + } + + if len(comment.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "empty": true, + "html": "", + }) + return + } + + html, err := ctx.HTMLString(string(tplReactions), map[string]interface{}{ + "ctx": ctx.Data, + "ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), + "Reactions": comment.Reactions.GroupByType(), + }) + if err != nil { + ctx.ServerError("ChangeCommentReaction.HTMLString", err) + return + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "html": html, + }) +} + +func addParticipant(poster *models.User, participants []*models.User) []*models.User { + for _, part := range participants { + if poster.ID == part.ID { + return participants + } + } + return append(participants, poster) +} + +func filterXRefComments(ctx *context.Context, issue *models.Issue) error { + // Remove comments that the user has no permissions to see + for i := 0; i < len(issue.Comments); { + c := issue.Comments[i] + if models.CommentTypeIsRef(c.Type) && c.RefRepoID != issue.RepoID && c.RefRepoID != 0 { + var err error + // Set RefRepo for description in template + c.RefRepo, err = models.GetRepositoryByID(c.RefRepoID) + if err != nil { + return err + } + perm, err := models.GetUserRepoPermission(c.RefRepo, ctx.User) + if err != nil { + return err + } + if !perm.CanReadIssuesOrPulls(c.RefIsPull) { + issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...) + continue + } + } + i++ + } + return nil +} + +// GetIssueAttachments returns attachments for the issue +func GetIssueAttachments(ctx *context.Context) { + issue := GetActionIssue(ctx) + var attachments = make([]*api.Attachment, len(issue.Attachments)) + for i := 0; i < len(issue.Attachments); i++ { + attachments[i] = convert.ToReleaseAttachment(issue.Attachments[i]) + } + ctx.JSON(http.StatusOK, attachments) +} + +// GetCommentAttachments returns attachments for the comment +func GetCommentAttachments(ctx *context.Context) { + comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err) + return + } + var attachments = make([]*api.Attachment, 0) + if comment.Type == models.CommentTypeComment { + if err := comment.LoadAttachments(); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + for i := 0; i < len(comment.Attachments); i++ { + attachments = append(attachments, convert.ToReleaseAttachment(comment.Attachments[i])) + } + } + ctx.JSON(http.StatusOK, attachments) +} + +func updateAttachments(item interface{}, files []string) error { + var attachments []*models.Attachment + switch content := item.(type) { + case *models.Issue: + attachments = content.Attachments + case *models.Comment: + attachments = content.Attachments + default: + return fmt.Errorf("Unknown Type: %T", content) + } + for i := 0; i < len(attachments); i++ { + if util.IsStringInSlice(attachments[i].UUID, files) { + continue + } + if err := models.DeleteAttachment(attachments[i], true); err != nil { + return err + } + } + var err error + if len(files) > 0 { + switch content := item.(type) { + case *models.Issue: + err = content.UpdateAttachments(files) + case *models.Comment: + err = content.UpdateAttachments(files) + default: + return fmt.Errorf("Unknown Type: %T", content) + } + if err != nil { + return err + } + } + switch content := item.(type) { + case *models.Issue: + content.Attachments, err = models.GetAttachmentsByIssueID(content.ID) + case *models.Comment: + content.Attachments, err = models.GetAttachmentsByCommentID(content.ID) + default: + return fmt.Errorf("Unknown Type: %T", content) + } + return err +} + +func attachmentsHTML(ctx *context.Context, attachments []*models.Attachment, content string) string { + attachHTML, err := ctx.HTMLString(string(tplAttachment), map[string]interface{}{ + "ctx": ctx.Data, + "Attachments": attachments, + "Content": content, + }) + if err != nil { + ctx.ServerError("attachmentsHTML.HTMLString", err) + return "" + } + return attachHTML +} + +// combineLabelComments combine the nearby label comments as one. +func combineLabelComments(issue *models.Issue) { + var prev, cur *models.Comment + for i := 0; i < len(issue.Comments); i++ { + cur = issue.Comments[i] + if i > 0 { + prev = issue.Comments[i-1] + } + if i == 0 || cur.Type != models.CommentTypeLabel || + (prev != nil && prev.PosterID != cur.PosterID) || + (prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) { + if cur.Type == models.CommentTypeLabel && cur.Label != nil { + if cur.Content != "1" { + cur.RemovedLabels = append(cur.RemovedLabels, cur.Label) + } else { + cur.AddedLabels = append(cur.AddedLabels, cur.Label) + } + } + continue + } + + if cur.Label != nil { // now cur MUST be label comment + if prev.Type == models.CommentTypeLabel { // we can combine them only prev is a label comment + if cur.Content != "1" { + prev.RemovedLabels = append(prev.RemovedLabels, cur.Label) + } else { + prev.AddedLabels = append(prev.AddedLabels, cur.Label) + } + prev.CreatedUnix = cur.CreatedUnix + // remove the current comment since it has been combined to prev comment + issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...) + i-- + } else { // if prev is not a label comment, start a new group + if cur.Content != "1" { + cur.RemovedLabels = append(cur.RemovedLabels, cur.Label) + } else { + cur.AddedLabels = append(cur.AddedLabels, cur.Label) + } + } + } + } +} + +// get all teams that current user can mention +func handleTeamMentions(ctx *context.Context) { + if ctx.User == nil || !ctx.Repo.Owner.IsOrganization() { + return + } + + isAdmin := false + var err error + // Admin has super access. + if ctx.User.IsAdmin { + isAdmin = true + } else { + isAdmin, err = ctx.Repo.Owner.IsOwnedBy(ctx.User.ID) + if err != nil { + ctx.ServerError("IsOwnedBy", err) + return + } + } + + if isAdmin { + if err := ctx.Repo.Owner.GetTeams(&models.SearchTeamOptions{}); err != nil { + ctx.ServerError("GetTeams", err) + return + } + } else { + ctx.Repo.Owner.Teams, err = ctx.Repo.Owner.GetUserTeams(ctx.User.ID) + if err != nil { + ctx.ServerError("GetUserTeams", err) + return + } + } + + ctx.Data["MentionableTeams"] = ctx.Repo.Owner.Teams + ctx.Data["MentionableTeamsOrg"] = ctx.Repo.Owner.Name + ctx.Data["MentionableTeamsOrgAvatar"] = ctx.Repo.Owner.RelAvatarLink() +} diff --git a/routers/web/repo/issue_dependency.go b/routers/web/repo/issue_dependency.go new file mode 100644 index 0000000000..8a83c7bae3 --- /dev/null +++ b/routers/web/repo/issue_dependency.go @@ -0,0 +1,129 @@ +// 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 repo + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +// AddDependency adds new dependencies +func AddDependency(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.ServerError("GetIssueByIndex", err) + return + } + + // Check if the Repo is allowed to have dependencies + if !ctx.Repo.CanCreateIssueDependencies(ctx.User, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + return + } + + depID := ctx.QueryInt64("newDependency") + + if err = issue.LoadRepo(); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Redirect + defer ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) + + // Dependency + dep, err := models.GetIssueByID(depID) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_issue_not_exist")) + return + } + + // Check if both issues are in the same repo if cross repository dependencies is not enabled + if issue.RepoID != dep.RepoID && !setting.Service.AllowCrossRepositoryDependencies { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) + return + } + + // Check if issue and dependency is the same + if dep.ID == issue.ID { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_same_issue")) + return + } + + err = models.CreateIssueDependency(ctx.User, issue, dep) + if err != nil { + if models.IsErrDependencyExists(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_exists")) + return + } else if models.IsErrCircularDependency(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_cannot_create_circular")) + return + } else { + ctx.ServerError("CreateOrUpdateIssueDependency", err) + return + } + } +} + +// RemoveDependency removes the dependency +func RemoveDependency(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.ServerError("GetIssueByIndex", err) + return + } + + // Check if the Repo is allowed to have dependencies + if !ctx.Repo.CanCreateIssueDependencies(ctx.User, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + return + } + + depID := ctx.QueryInt64("removeDependencyID") + + if err = issue.LoadRepo(); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Dependency Type + depTypeStr := ctx.Req.PostForm.Get("dependencyType") + + var depType models.DependencyType + + switch depTypeStr { + case "blockedBy": + depType = models.DependencyTypeBlockedBy + case "blocking": + depType = models.DependencyTypeBlocking + default: + ctx.Error(http.StatusBadRequest, "GetDependecyType") + return + } + + // Dependency + dep, err := models.GetIssueByID(depID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if err = models.RemoveIssueDependency(ctx.User, issue, dep, depType); err != nil { + if models.IsErrDependencyNotExists(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist")) + return + } + ctx.ServerError("RemoveIssueDependency", err) + return + } + + // Redirect + ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) +} diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go new file mode 100644 index 0000000000..73612606c8 --- /dev/null +++ b/routers/web/repo/issue_label.go @@ -0,0 +1,222 @@ +// 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 repo + +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/log" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" +) + +const ( + tplLabels base.TplName = "repo/issue/labels" +) + +// Labels render issue's labels page +func Labels(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsLabels"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["LabelTemplates"] = models.LabelTemplates + ctx.HTML(http.StatusOK, tplLabels) +} + +// InitializeLabels init labels for a repository +func InitializeLabels(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.InitializeLabelsForm) + if ctx.HasError() { + ctx.Redirect(ctx.Repo.RepoLink + "/labels") + return + } + + if err := models.InitializeLabels(models.DefaultDBContext(), ctx.Repo.Repository.ID, form.TemplateName, false); err != nil { + if models.IsErrIssueLabelTemplateLoad(err) { + originalErr := err.(models.ErrIssueLabelTemplateLoad).OriginalError + ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) + ctx.Redirect(ctx.Repo.RepoLink + "/labels") + return + } + ctx.ServerError("InitializeLabels", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/labels") +} + +// RetrieveLabels find all the labels of a repository and organization +func RetrieveLabels(ctx *context.Context) { + labels, err := models.GetLabelsByRepoID(ctx.Repo.Repository.ID, ctx.Query("sort"), models.ListOptions{}) + if err != nil { + ctx.ServerError("RetrieveLabels.GetLabels", err) + return + } + + for _, l := range labels { + l.CalOpenIssues() + } + + ctx.Data["Labels"] = labels + + if ctx.Repo.Owner.IsOrganization() { + orgLabels, err := models.GetLabelsByOrgID(ctx.Repo.Owner.ID, ctx.Query("sort"), models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + for _, l := range orgLabels { + l.CalOpenOrgIssues(ctx.Repo.Repository.ID, l.ID) + } + ctx.Data["OrgLabels"] = orgLabels + + org, err := models.GetOrgByName(ctx.Repo.Owner.LowerName) + if err != nil { + ctx.ServerError("GetOrgByName", err) + return + } + if ctx.User != nil { + ctx.Org.IsOwner, err = org.IsOwnedBy(ctx.User.ID) + if err != nil { + ctx.ServerError("org.IsOwnedBy", err) + return + } + ctx.Org.OrgLink = org.OrganisationLink() + ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner + ctx.Data["OrganizationLink"] = ctx.Org.OrgLink + } + } + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SortType"] = ctx.Query("sort") +} + +// NewLabel create new label for repository +func NewLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsLabels"] = true + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(ctx.Repo.RepoLink + "/labels") + return + } + + l := &models.Label{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Description: form.Description, + Color: form.Color, + } + if err := models.NewLabel(l); err != nil { + ctx.ServerError("NewLabel", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/labels") +} + +// UpdateLabel update a label's name and color +func UpdateLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + l, err := models.GetLabelInRepoByID(ctx.Repo.Repository.ID, form.ID) + if err != nil { + switch { + case models.IsErrRepoLabelNotExist(err): + ctx.Error(http.StatusNotFound) + default: + ctx.ServerError("UpdateLabel", err) + } + return + } + + l.Name = form.Title + l.Description = form.Description + l.Color = form.Color + if err := models.UpdateLabel(l); err != nil { + ctx.ServerError("UpdateLabel", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/labels") +} + +// DeleteLabel delete a label +func DeleteLabel(ctx *context.Context) { + if err := models.DeleteLabel(ctx.Repo.Repository.ID, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteLabel: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/labels", + }) +} + +// UpdateIssueLabel change issue's labels +func UpdateIssueLabel(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + switch action := ctx.Query("action"); action { + case "clear": + for _, issue := range issues { + if err := issue_service.ClearLabels(issue, ctx.User); err != nil { + ctx.ServerError("ClearLabels", err) + return + } + } + case "attach", "detach", "toggle": + label, err := models.GetLabelByID(ctx.QueryInt64("id")) + if err != nil { + if models.IsErrRepoLabelNotExist(err) { + ctx.Error(http.StatusNotFound, "GetLabelByID") + } else { + ctx.ServerError("GetLabelByID", err) + } + return + } + + if action == "toggle" { + // detach if any issues already have label, otherwise attach + action = "attach" + for _, issue := range issues { + if issue.HasLabel(label.ID) { + action = "detach" + break + } + } + } + + if action == "attach" { + for _, issue := range issues { + if err = issue_service.AddLabel(issue, ctx.User, label); err != nil { + ctx.ServerError("AddLabel", err) + return + } + } + } else { + for _, issue := range issues { + if err = issue_service.RemoveLabel(issue, ctx.User, label); err != nil { + ctx.ServerError("RemoveLabel", err) + return + } + } + } + default: + log.Warn("Unrecognized action: %s", action) + ctx.Error(http.StatusInternalServerError) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} diff --git a/routers/web/repo/issue_label_test.go b/routers/web/repo/issue_label_test.go new file mode 100644 index 0000000000..bf9e72a6f4 --- /dev/null +++ b/routers/web/repo/issue_label_test.go @@ -0,0 +1,168 @@ +// 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 repo + +import ( + "net/http" + "strconv" + "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 int64SliceToCommaSeparated(a []int64) string { + s := "" + for i, n := range a { + if i > 0 { + s += "," + } + s += strconv.Itoa(int(n)) + } + return s +} + +func TestInitializeLabels(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/labels/initialize") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 2) + web.SetForm(ctx, &forms.InitializeLabelsForm{TemplateName: "Default"}) + InitializeLabels(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + models.AssertExistsAndLoadBean(t, &models.Label{ + RepoID: 2, + Name: "enhancement", + Color: "#84b6eb", + }) + assert.Equal(t, "/user2/repo2/labels", test.RedirectURL(ctx.Resp)) +} + +func TestRetrieveLabels(t *testing.T) { + models.PrepareTestEnv(t) + for _, testCase := range []struct { + RepoID int64 + Sort string + ExpectedLabelIDs []int64 + }{ + {1, "", []int64{1, 2}}, + {1, "leastissues", []int64{2, 1}}, + {2, "", []int64{}}, + } { + ctx := test.MockContext(t, "user/repo/issues") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, testCase.RepoID) + ctx.Req.Form.Set("sort", testCase.Sort) + RetrieveLabels(ctx) + assert.False(t, ctx.Written()) + labels, ok := ctx.Data["Labels"].([]*models.Label) + assert.True(t, ok) + if assert.Len(t, labels, len(testCase.ExpectedLabelIDs)) { + for i, label := range labels { + assert.EqualValues(t, testCase.ExpectedLabelIDs[i], label.ID) + } + } + } +} + +func TestNewLabel(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/labels/edit") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + Title: "newlabel", + Color: "#abcdef", + }) + NewLabel(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + models.AssertExistsAndLoadBean(t, &models.Label{ + Name: "newlabel", + Color: "#abcdef", + }) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) +} + +func TestUpdateLabel(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/labels/edit") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + ID: 2, + Title: "newnameforlabel", + Color: "#abcdef", + }) + UpdateLabel(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + models.AssertExistsAndLoadBean(t, &models.Label{ + ID: 2, + Name: "newnameforlabel", + Color: "#abcdef", + }) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) +} + +func TestDeleteLabel(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/labels/delete") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + ctx.Req.Form.Set("id", "2") + DeleteLabel(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + models.AssertNotExistsBean(t, &models.Label{ID: 2}) + models.AssertNotExistsBean(t, &models.IssueLabel{LabelID: 2}) + assert.Equal(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg) +} + +func TestUpdateIssueLabel_Clear(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/issues/labels") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + ctx.Req.Form.Set("issue_ids", "1,3") + ctx.Req.Form.Set("action", "clear") + UpdateIssueLabel(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + models.AssertNotExistsBean(t, &models.IssueLabel{IssueID: 1}) + models.AssertNotExistsBean(t, &models.IssueLabel{IssueID: 3}) + models.CheckConsistencyFor(t, &models.Label{}) +} + +func TestUpdateIssueLabel_Toggle(t *testing.T) { + for _, testCase := range []struct { + Action string + IssueIDs []int64 + LabelID int64 + ExpectedAdd bool // whether we expect the label to be added to the issues + }{ + {"attach", []int64{1, 3}, 1, true}, + {"detach", []int64{1, 3}, 1, false}, + {"toggle", []int64{1, 3}, 1, false}, + {"toggle", []int64{1, 2}, 2, true}, + } { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/issues/labels") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + ctx.Req.Form.Set("issue_ids", int64SliceToCommaSeparated(testCase.IssueIDs)) + ctx.Req.Form.Set("action", testCase.Action) + ctx.Req.Form.Set("id", strconv.Itoa(int(testCase.LabelID))) + UpdateIssueLabel(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + for _, issueID := range testCase.IssueIDs { + models.AssertExistsIf(t, testCase.ExpectedAdd, &models.IssueLabel{ + IssueID: issueID, + LabelID: testCase.LabelID, + }) + } + models.CheckConsistencyFor(t, &models.Label{}) + } +} diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go new file mode 100644 index 0000000000..36894b4be3 --- /dev/null +++ b/routers/web/repo/issue_lock.go @@ -0,0 +1,72 @@ +// 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 repo + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +// LockIssue locks an issue. This would limit commenting abilities to +// users with write access to the repo. +func LockIssue(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.IssueLockForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if issue.IsLocked { + ctx.Flash.Error(ctx.Tr("repo.issues.lock_duplicate")) + ctx.Redirect(issue.HTMLURL()) + return + } + + if !form.HasValidReason() { + ctx.Flash.Error(ctx.Tr("repo.issues.lock.unknown_reason")) + ctx.Redirect(issue.HTMLURL()) + return + } + + if err := models.LockIssue(&models.IssueLockOptions{ + Doer: ctx.User, + Issue: issue, + Reason: form.Reason, + }); err != nil { + ctx.ServerError("LockIssue", err) + return + } + + ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) +} + +// UnlockIssue unlocks a previously locked issue. +func UnlockIssue(ctx *context.Context) { + + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !issue.IsLocked { + ctx.Flash.Error(ctx.Tr("repo.issues.unlock_error")) + ctx.Redirect(issue.HTMLURL()) + return + } + + if err := models.UnlockIssue(&models.IssueLockOptions{ + Doer: ctx.User, + Issue: issue, + }); err != nil { + ctx.ServerError("UnlockIssue", err) + return + } + + ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) +} diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go new file mode 100644 index 0000000000..b8efb3b841 --- /dev/null +++ b/routers/web/repo/issue_stopwatch.go @@ -0,0 +1,108 @@ +// 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 repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" +) + +// IssueStopwatch creates or stops a stopwatch for the given issue. +func IssueStopwatch(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { + return + } + + var showSuccessMessage bool + + if !models.StopwatchExists(c.User.ID, issue.ID) { + showSuccessMessage = true + } + + if !c.Repo.CanUseTimetracker(issue, c.User) { + c.NotFound("CanUseTimetracker", nil) + return + } + + if err := models.CreateOrStopIssueStopwatch(c.User, issue); err != nil { + c.ServerError("CreateOrStopIssueStopwatch", err) + return + } + + if showSuccessMessage { + c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) + } + + url := issue.HTMLURL() + c.Redirect(url, http.StatusSeeOther) +} + +// CancelStopwatch cancel the stopwatch +func CancelStopwatch(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { + return + } + if !c.Repo.CanUseTimetracker(issue, c.User) { + c.NotFound("CanUseTimetracker", nil) + return + } + + if err := models.CancelStopwatch(c.User, issue); err != nil { + c.ServerError("CancelStopwatch", err) + return + } + + url := issue.HTMLURL() + c.Redirect(url, http.StatusSeeOther) +} + +// GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context +func GetActiveStopwatch(c *context.Context) { + if strings.HasPrefix(c.Req.URL.Path, "/api") { + return + } + + if !c.IsSigned { + return + } + + _, sw, err := models.HasUserStopwatch(c.User.ID) + if err != nil { + c.ServerError("HasUserStopwatch", err) + return + } + + if sw == nil || sw.ID == 0 { + return + } + + issue, err := models.GetIssueByID(sw.IssueID) + if err != nil || issue == nil { + c.ServerError("GetIssueByID", err) + return + } + if err = issue.LoadRepo(); err != nil { + c.ServerError("LoadRepo", err) + return + } + + c.Data["ActiveStopwatch"] = StopwatchTmplInfo{ + issue.Repo.FullName(), + issue.Index, + sw.Seconds() + 1, // ensure time is never zero in ui + } +} + +// StopwatchTmplInfo is a view on a stopwatch specifically for template rendering +type StopwatchTmplInfo struct { + RepoSlug string + IssueIndex int64 + Seconds int64 +} diff --git a/routers/web/repo/issue_test.go b/routers/web/repo/issue_test.go new file mode 100644 index 0000000000..7fb837fa12 --- /dev/null +++ b/routers/web/repo/issue_test.go @@ -0,0 +1,324 @@ +// Copyright 2020 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 repo + +import ( + "testing" + + "code.gitea.io/gitea/models" + "github.com/stretchr/testify/assert" +) + +func TestCombineLabelComments(t *testing.T) { + var kases = []struct { + name string + beforeCombined []*models.Comment + afterCombined []*models.Comment + }{ + { + name: "kase 1", + beforeCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + RemovedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + Label: &models.Label{ + Name: "kind/bug", + }, + }, + { + Type: models.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 2", + beforeCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 70, + }, + { + Type: models.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + Label: &models.Label{ + Name: "kind/bug", + }, + }, + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "", + CreatedUnix: 70, + RemovedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + Label: &models.Label{ + Name: "kind/bug", + }, + }, + { + Type: models.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 3", + beforeCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeLabel, + PosterID: 2, + Content: "", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + Label: &models.Label{ + Name: "kind/bug", + }, + }, + { + Type: models.CommentTypeLabel, + PosterID: 2, + Content: "", + CreatedUnix: 0, + RemovedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + Label: &models.Label{ + Name: "kind/bug", + }, + }, + { + Type: models.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 4", + beforeCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &models.Label{ + Name: "kind/backport", + }, + CreatedUnix: 10, + }, + }, + afterCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 10, + AddedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + { + Name: "kind/backport", + }, + }, + Label: &models.Label{ + Name: "kind/bug", + }, + }, + }, + }, + { + name: "kase 5", + beforeCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeComment, + PosterID: 2, + Content: "testtest", + CreatedUnix: 0, + }, + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + }, + afterCombined: []*models.Comment{ + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &models.Label{ + Name: "kind/bug", + }, + AddedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + CreatedUnix: 0, + }, + { + Type: models.CommentTypeComment, + PosterID: 2, + Content: "testtest", + CreatedUnix: 0, + }, + { + Type: models.CommentTypeLabel, + PosterID: 1, + Content: "", + RemovedLabels: []*models.Label{ + { + Name: "kind/bug", + }, + }, + Label: &models.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + }, + }, + } + + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + var issue = models.Issue{ + Comments: kase.beforeCombined, + } + combineLabelComments(&issue) + assert.EqualValues(t, kase.afterCombined, issue.Comments) + }) + } +} diff --git a/routers/web/repo/issue_timetrack.go b/routers/web/repo/issue_timetrack.go new file mode 100644 index 0000000000..3770cd7b4e --- /dev/null +++ b/routers/web/repo/issue_timetrack.go @@ -0,0 +1,86 @@ +// 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 repo + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +// AddTimeManually tracks time manually +func AddTimeManually(c *context.Context) { + form := web.GetForm(c).(*forms.AddTimeManuallyForm) + issue := GetActionIssue(c) + if c.Written() { + return + } + if !c.Repo.CanUseTimetracker(issue, c.User) { + c.NotFound("CanUseTimetracker", nil) + return + } + url := issue.HTMLURL() + + if c.HasError() { + c.Flash.Error(c.GetErrMsg()) + c.Redirect(url) + return + } + + total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute + + if total <= 0 { + c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small")) + c.Redirect(url, http.StatusSeeOther) + return + } + + if _, err := models.AddTime(c.User, issue, int64(total.Seconds()), time.Now()); err != nil { + c.ServerError("AddTime", err) + return + } + + c.Redirect(url, http.StatusSeeOther) +} + +// DeleteTime deletes tracked time +func DeleteTime(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { + return + } + if !c.Repo.CanUseTimetracker(issue, c.User) { + c.NotFound("CanUseTimetracker", nil) + return + } + + t, err := models.GetTrackedTimeByID(c.ParamsInt64(":timeid")) + if err != nil { + if models.IsErrNotExist(err) { + c.NotFound("time not found", err) + return + } + c.Error(http.StatusInternalServerError, "GetTrackedTimeByID", err.Error()) + return + } + + // only OP or admin may delete + if !c.IsSigned || (!c.IsUserSiteAdmin() && c.User.ID != t.UserID) { + c.Error(http.StatusForbidden, "not allowed") + return + } + + if err = models.DeleteTime(t); err != nil { + c.ServerError("DeleteTime", err) + return + } + + c.Flash.Success(c.Tr("repo.issues.del_time_history", models.SecToTime(t.Time))) + c.Redirect(issue.HTMLURL()) +} diff --git a/routers/web/repo/issue_watch.go b/routers/web/repo/issue_watch.go new file mode 100644 index 0000000000..dabbff842b --- /dev/null +++ b/routers/web/repo/issue_watch.go @@ -0,0 +1,57 @@ +// 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 repo + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" +) + +// IssueWatch sets issue watching +func IssueWatch(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.User.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.User, + log.NewColoredIDValue(issue.PosterID), + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + ctx.Error(http.StatusForbidden) + return + } + + watch, err := strconv.ParseBool(ctx.Req.PostForm.Get("watch")) + if err != nil { + ctx.ServerError("watch is not bool", err) + return + } + + if err := models.CreateOrUpdateIssueWatch(ctx.User.ID, issue.ID, watch); err != nil { + ctx.ServerError("CreateOrUpdateIssueWatch", err) + return + } + + ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) +} diff --git a/routers/web/repo/lfs.go b/routers/web/repo/lfs.go new file mode 100644 index 0000000000..173ffb773f --- /dev/null +++ b/routers/web/repo/lfs.go @@ -0,0 +1,537 @@ +// 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 repo + +import ( + "bytes" + "fmt" + gotemplate "html/template" + "io" + "io/ioutil" + "net/http" + "path" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/pipeline" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/typesniffer" +) + +const ( + tplSettingsLFS base.TplName = "repo/settings/lfs" + tplSettingsLFSLocks base.TplName = "repo/settings/lfs_locks" + tplSettingsLFSFile base.TplName = "repo/settings/lfs_file" + tplSettingsLFSFileFind base.TplName = "repo/settings/lfs_file_find" + tplSettingsLFSPointers base.TplName = "repo/settings/lfs_pointers" +) + +// LFSFiles shows a repository's LFS files +func LFSFiles(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFiles", nil) + return + } + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + total, err := ctx.Repo.Repository.CountLFSMetaObjects() + if err != nil { + ctx.ServerError("LFSFiles", err) + return + } + ctx.Data["Total"] = total + + pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5) + ctx.Data["Title"] = ctx.Tr("repo.settings.lfs") + ctx.Data["PageIsSettingsLFS"] = true + lfsMetaObjects, err := ctx.Repo.Repository.GetLFSMetaObjects(pager.Paginater.Current(), setting.UI.ExplorePagingNum) + if err != nil { + ctx.ServerError("LFSFiles", err) + return + } + ctx.Data["LFSFiles"] = lfsMetaObjects + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsLFS) +} + +// LFSLocks shows a repository's LFS locks +func LFSLocks(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSLocks", nil) + return + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + total, err := models.CountLFSLockByRepoID(ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("LFSLocks", err) + return + } + ctx.Data["Total"] = total + + pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5) + ctx.Data["Title"] = ctx.Tr("repo.settings.lfs_locks") + ctx.Data["PageIsSettingsLFS"] = true + lfsLocks, err := models.GetLFSLockByRepoID(ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum) + if err != nil { + ctx.ServerError("LFSLocks", err) + return + } + ctx.Data["LFSLocks"] = lfsLocks + + if len(lfsLocks) == 0 { + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsLFSLocks) + return + } + + // Clone base repo. + tmpBasePath, err := models.CreateTemporaryPath("locks") + if err != nil { + log.Error("Failed to create temporary path: %v", err) + ctx.ServerError("LFSLocks", err) + return + } + defer func() { + if err := models.RemoveTemporaryPath(tmpBasePath); err != nil { + log.Error("LFSLocks: RemoveTemporaryPath: %v", err) + } + }() + + if err := git.Clone(ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{ + Bare: true, + Shared: true, + }); err != nil { + log.Error("Failed to clone repository: %s (%v)", ctx.Repo.Repository.FullName(), err) + ctx.ServerError("LFSLocks", fmt.Errorf("Failed to clone repository: %s (%v)", ctx.Repo.Repository.FullName(), err)) + return + } + + gitRepo, err := git.OpenRepository(tmpBasePath) + if err != nil { + log.Error("Unable to open temporary repository: %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", fmt.Errorf("Failed to open new temporary repository in: %s %v", tmpBasePath, err)) + return + } + defer gitRepo.Close() + + filenames := make([]string, len(lfsLocks)) + + for i, lock := range lfsLocks { + filenames[i] = lock.Path + } + + if err := gitRepo.ReadTreeToIndex(ctx.Repo.Repository.DefaultBranch); err != nil { + log.Error("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err) + ctx.ServerError("LFSLocks", fmt.Errorf("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err)) + return + } + + name2attribute2info, err := gitRepo.CheckAttribute(git.CheckAttributeOpts{ + Attributes: []string{"lockable"}, + Filenames: filenames, + CachedOnly: true, + }) + if err != nil { + log.Error("Unable to check attributes in %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", err) + return + } + + lockables := make([]bool, len(lfsLocks)) + for i, lock := range lfsLocks { + attribute2info, has := name2attribute2info[lock.Path] + if !has { + continue + } + if attribute2info["lockable"] != "set" { + continue + } + lockables[i] = true + } + ctx.Data["Lockables"] = lockables + + filelist, err := gitRepo.LsFiles(filenames...) + if err != nil { + log.Error("Unable to lsfiles in %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", err) + return + } + + filemap := make(map[string]bool, len(filelist)) + for _, name := range filelist { + filemap[name] = true + } + + linkable := make([]bool, len(lfsLocks)) + for i, lock := range lfsLocks { + linkable[i] = filemap[lock.Path] + } + ctx.Data["Linkable"] = linkable + + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsLFSLocks) +} + +// LFSLockFile locks a file +func LFSLockFile(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSLocks", nil) + return + } + originalPath := ctx.Query("path") + lockPath := originalPath + if len(lockPath) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + if lockPath[len(lockPath)-1] == '/' { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_lock_directory", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + lockPath = path.Clean("/" + lockPath)[1:] + if len(lockPath) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + + _, err := models.CreateLFSLock(&models.LFSLock{ + Repo: ctx.Repo.Repository, + Path: lockPath, + Owner: ctx.User, + }) + if err != nil { + if models.IsErrLFSLockAlreadyExist(err) { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_lock_already_exists", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + ctx.ServerError("LFSLockFile", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") +} + +// LFSUnlock forcibly unlocks an LFS lock +func LFSUnlock(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSUnlock", nil) + return + } + _, err := models.DeleteLFSLockByID(ctx.ParamsInt64("lid"), ctx.User, true) + if err != nil { + ctx.ServerError("LFSUnlock", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") +} + +// LFSFileGet serves a single LFS file +func LFSFileGet(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + oid := ctx.Params("oid") + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + meta, err := ctx.Repo.Repository.GetLFSMetaObjectByOid(oid) + if err != nil { + if err == models.ErrLFSObjectNotExist { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.ServerError("LFSFileGet", err) + return + } + ctx.Data["LFSFile"] = meta + dataRc, err := lfs.ReadMetaObject(meta.Pointer) + if err != nil { + ctx.ServerError("LFSFileGet", err) + return + } + defer dataRc.Close() + buf := make([]byte, 1024) + n, err := dataRc.Read(buf) + if err != nil { + ctx.ServerError("Data", err) + return + } + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + ctx.Data["IsTextFile"] = st.IsText() + isRepresentableAsText := st.IsRepresentableAsText() + + fileSize := meta.Size + ctx.Data["FileSize"] = meta.Size + ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s.git/info/lfs/objects/%s/%s", setting.AppURL, ctx.Repo.Repository.FullName(), meta.Oid, "direct") + switch { + case isRepresentableAsText: + if st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + } + + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + buf := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc)) + + // Building code view blocks with line number on server side. + fileContent, _ := ioutil.ReadAll(buf) + + var output bytes.Buffer + lines := strings.Split(string(fileContent), "\n") + //Remove blank line at the end of file + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + for index, line := range lines { + line = gotemplate.HTMLEscapeString(line) + if index != len(lines)-1 { + line += "\n" + } + output.WriteString(fmt.Sprintf(`<li class="L%d" rel="L%d">%s</li>`, index+1, index+1, line)) + } + ctx.Data["FileContent"] = gotemplate.HTML(output.String()) + + output.Reset() + for i := 0; i < len(lines); i++ { + output.WriteString(fmt.Sprintf(`<span id="L%d">%d</span>`, i+1, i+1)) + } + ctx.Data["LineNums"] = gotemplate.HTML(output.String()) + + case st.IsPDF(): + ctx.Data["IsPDFFile"] = true + case st.IsVideo(): + ctx.Data["IsVideoFile"] = true + case st.IsAudio(): + ctx.Data["IsAudioFile"] = true + case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): + ctx.Data["IsImageFile"] = true + } + ctx.HTML(http.StatusOK, tplSettingsLFSFile) +} + +// LFSDelete disassociates the provided oid from the repository and if the lfs file is no longer associated with any repositories - deletes it +func LFSDelete(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSDelete", nil) + return + } + oid := ctx.Params("oid") + count, err := ctx.Repo.Repository.RemoveLFSMetaObjectByOid(oid) + if err != nil { + ctx.ServerError("LFSDelete", err) + return + } + // FIXME: Warning: the LFS store is not locked - and can't be locked - there could be a race condition here + // Please note a similar condition happens in models/repo.go DeleteRepository + if count == 0 { + oidPath := path.Join(oid[0:2], oid[2:4], oid[4:]) + err = storage.LFS.Delete(oidPath) + if err != nil { + ctx.ServerError("LFSDelete", err) + return + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs") +} + +// LFSFileFind guesses a sha for the provided oid (or uses the provided sha) and then finds the commits that contain this sha +func LFSFileFind(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFind", nil) + return + } + oid := ctx.Query("oid") + size := ctx.QueryInt64("size") + if len(oid) == 0 || size == 0 { + ctx.NotFound("LFSFind", nil) + return + } + sha := ctx.Query("sha") + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + var hash git.SHA1 + if len(sha) == 0 { + pointer := lfs.Pointer{Oid: oid, Size: size} + hash = git.ComputeBlobHash([]byte(pointer.StringContent())) + sha = hash.String() + } else { + hash = git.MustIDFromString(sha) + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + ctx.Data["Oid"] = oid + ctx.Data["Size"] = size + ctx.Data["SHA"] = sha + + results, err := pipeline.FindLFSFile(ctx.Repo.GitRepo, hash) + if err != nil && err != io.EOF { + log.Error("Failure in FindLFSFile: %v", err) + ctx.ServerError("LFSFind: FindLFSFile.", err) + return + } + + ctx.Data["Results"] = results + ctx.HTML(http.StatusOK, tplSettingsLFSFileFind) +} + +// LFSPointerFiles will search the repository for pointer files and report which are missing LFS files in the content store +func LFSPointerFiles(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.Data["PageIsSettingsLFS"] = true + err := git.LoadGitVersion() + if err != nil { + log.Fatal("Error retrieving git version: %v", err) + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + + err = func() error { + pointerChan := make(chan lfs.PointerBlob) + errChan := make(chan error, 1) + go lfs.SearchPointerBlobs(ctx, ctx.Repo.GitRepo, pointerChan, errChan) + + numPointers := 0 + var numAssociated, numNoExist, numAssociatable int + + type pointerResult struct { + SHA string + Oid string + Size int64 + InRepo bool + Exists bool + Accessible bool + } + + results := []pointerResult{} + + contentStore := lfs.NewContentStore() + repo := ctx.Repo.Repository + + for pointerBlob := range pointerChan { + numPointers++ + + result := pointerResult{ + SHA: pointerBlob.Hash, + Oid: pointerBlob.Oid, + Size: pointerBlob.Size, + } + + if _, err := repo.GetLFSMetaObjectByOid(pointerBlob.Oid); err != nil { + if err != models.ErrLFSObjectNotExist { + return err + } + } else { + result.InRepo = true + } + + result.Exists, err = contentStore.Exists(pointerBlob.Pointer) + if err != nil { + return err + } + + if result.Exists { + if !result.InRepo { + // Can we fix? + // OK well that's "simple" + // - we need to check whether current user has access to a repo that has access to the file + result.Accessible, err = models.LFSObjectAccessible(ctx.User, pointerBlob.Oid) + if err != nil { + return err + } + } else { + result.Accessible = true + } + } + + if result.InRepo { + numAssociated++ + } + if !result.Exists { + numNoExist++ + } + if !result.InRepo && result.Accessible { + numAssociatable++ + } + + results = append(results, result) + } + + err, has := <-errChan + if has { + return err + } + + ctx.Data["Pointers"] = results + ctx.Data["NumPointers"] = numPointers + ctx.Data["NumAssociated"] = numAssociated + ctx.Data["NumAssociatable"] = numAssociatable + ctx.Data["NumNoExist"] = numNoExist + ctx.Data["NumNotAssociated"] = numPointers - numAssociated + + return nil + }() + if err != nil { + ctx.ServerError("LFSPointerFiles", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsLFSPointers) +} + +// LFSAutoAssociate auto associates accessible lfs files +func LFSAutoAssociate(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSAutoAssociate", nil) + return + } + oids := ctx.QueryStrings("oid") + metas := make([]*models.LFSMetaObject, len(oids)) + for i, oid := range oids { + idx := strings.IndexRune(oid, ' ') + if idx < 0 || idx+1 > len(oid) { + ctx.ServerError("LFSAutoAssociate", fmt.Errorf("Illegal oid input: %s", oid)) + return + } + var err error + metas[i] = &models.LFSMetaObject{} + metas[i].Size, err = strconv.ParseInt(oid[idx+1:], 10, 64) + if err != nil { + ctx.ServerError("LFSAutoAssociate", fmt.Errorf("Illegal oid input: %s %v", oid, err)) + return + } + metas[i].Oid = oid[:idx] + //metas[i].RepositoryID = ctx.Repo.Repository.ID + } + if err := models.LFSAutoAssociate(metas, ctx.User, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("LFSAutoAssociate", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs") +} diff --git a/routers/web/repo/main_test.go b/routers/web/repo/main_test.go new file mode 100644 index 0000000000..47f266365f --- /dev/null +++ b/routers/web/repo/main_test.go @@ -0,0 +1,16 @@ +// 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 repo + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..", "..")) +} diff --git a/routers/web/repo/middlewares.go b/routers/web/repo/middlewares.go new file mode 100644 index 0000000000..1b95a13ba2 --- /dev/null +++ b/routers/web/repo/middlewares.go @@ -0,0 +1,72 @@ +// Copyright 2020 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 repo + +import ( + "fmt" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" +) + +// SetEditorconfigIfExists set editor config as render variable +func SetEditorconfigIfExists(ctx *context.Context) { + if ctx.Repo.Repository.IsEmpty { + ctx.Data["Editorconfig"] = nil + return + } + + ec, err := ctx.Repo.GetEditorconfig() + + if err != nil && !git.IsErrNotExist(err) { + description := fmt.Sprintf("Error while getting .editorconfig file: %v", err) + if err := models.CreateRepositoryNotice(description); err != nil { + ctx.ServerError("ErrCreatingReporitoryNotice", err) + } + return + } + + ctx.Data["Editorconfig"] = ec +} + +// SetDiffViewStyle set diff style as render variable +func SetDiffViewStyle(ctx *context.Context) { + queryStyle := ctx.Query("style") + + if !ctx.IsSigned { + ctx.Data["IsSplitStyle"] = queryStyle == "split" + return + } + + var ( + userStyle = ctx.User.DiffViewStyle + style string + ) + + if queryStyle == "unified" || queryStyle == "split" { + style = queryStyle + } else if userStyle == "unified" || userStyle == "split" { + style = userStyle + } else { + style = "unified" + } + + ctx.Data["IsSplitStyle"] = style == "split" + if err := ctx.User.UpdateDiffViewStyle(style); err != nil { + ctx.ServerError("ErrUpdateDiffViewStyle", err) + } +} + +// SetWhitespaceBehavior set whitespace behavior as render variable +func SetWhitespaceBehavior(ctx *context.Context) { + whitespaceBehavior := ctx.Query("whitespace") + switch whitespaceBehavior { + case "ignore-all", "ignore-eol", "ignore-change": + ctx.Data["WhitespaceBehavior"] = whitespaceBehavior + default: + ctx.Data["WhitespaceBehavior"] = "" + } +} diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go new file mode 100644 index 0000000000..24d4ef4099 --- /dev/null +++ b/routers/web/repo/migrate.go @@ -0,0 +1,254 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 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 repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/migrations" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/task" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplMigrate base.TplName = "repo/migrate/migrate" +) + +// Migrate render migration of repository page +func Migrate(ctx *context.Context) { + if setting.Repository.DisableMigrations { + ctx.Error(http.StatusForbidden, "Migrate: the site administrator has disabled migrations") + return + } + + serviceType := structs.GitServiceType(ctx.QueryInt("service_type")) + + setMigrationContextData(ctx, serviceType) + + if serviceType == 0 { + ctx.Data["Org"] = ctx.Query("org") + ctx.Data["Mirror"] = ctx.Query("mirror") + + ctx.HTML(http.StatusOK, tplMigrate) + return + } + + ctx.Data["private"] = getRepoPrivate(ctx) + ctx.Data["mirror"] = ctx.Query("mirror") == "1" + ctx.Data["lfs"] = ctx.Query("lfs") == "1" + ctx.Data["wiki"] = ctx.Query("wiki") == "1" + ctx.Data["milestones"] = ctx.Query("milestones") == "1" + ctx.Data["labels"] = ctx.Query("labels") == "1" + ctx.Data["issues"] = ctx.Query("issues") == "1" + ctx.Data["pull_requests"] = ctx.Query("pull_requests") == "1" + ctx.Data["releases"] = ctx.Query("releases") == "1" + + ctxUser := checkContextUser(ctx, ctx.QueryInt64("org")) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + ctx.HTML(http.StatusOK, base.TplName("repo/migrate/"+serviceType.Name())) +} + +func handleMigrateError(ctx *context.Context, owner *models.User, err error, name string, tpl base.TplName, form *forms.MigrateRepoForm) { + if setting.Repository.DisableMigrations { + ctx.Error(http.StatusForbidden, "MigrateError: the site administrator has disabled migrations") + return + } + + switch { + case migrations.IsRateLimitError(err): + ctx.RenderWithErr(ctx.Tr("form.visit_rate_limit"), tpl, form) + case migrations.IsTwoFactorAuthError(err): + ctx.RenderWithErr(ctx.Tr("form.2fa_auth_required"), tpl, form) + case models.IsErrReachLimitOfRepo(err): + ctx.RenderWithErr(ctx.Tr("repo.form.reach_limit_of_creation", owner.MaxCreationLimit()), tpl, form) + case models.IsErrRepoAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) + case models.IsErrRepoFilesAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tpl, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tpl, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tpl, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tpl, form) + } + case models.IsErrNameReserved(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form) + case models.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tpl, form) + default: + remoteAddr, _ := forms.ParseRemoteAddr(form.CloneAddr, form.AuthUsername, form.AuthPassword) + err = util.URLSanitizedError(err, remoteAddr) + if strings.Contains(err.Error(), "Authentication failed") || + strings.Contains(err.Error(), "Bad credentials") || + strings.Contains(err.Error(), "could not read Username") { + ctx.Data["Err_Auth"] = true + ctx.RenderWithErr(ctx.Tr("form.auth_failed", err.Error()), tpl, form) + } else if strings.Contains(err.Error(), "fatal:") { + ctx.Data["Err_CloneAddr"] = true + ctx.RenderWithErr(ctx.Tr("repo.migrate.failed", err.Error()), tpl, form) + } else { + ctx.ServerError(name, err) + } + } +} + +func handleMigrateRemoteAddrError(ctx *context.Context, err error, tpl base.TplName, form *forms.MigrateRepoForm) { + if models.IsErrInvalidCloneAddr(err) { + addrErr := err.(*models.ErrInvalidCloneAddr) + switch { + case addrErr.IsProtocolInvalid: + ctx.RenderWithErr(ctx.Tr("repo.mirror_address_protocol_invalid"), tpl, form) + case addrErr.IsURLError: + ctx.RenderWithErr(ctx.Tr("form.url_error"), tpl, form) + case addrErr.IsPermissionDenied: + if addrErr.LocalPath { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied"), tpl, form) + } else if len(addrErr.PrivateNet) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_blocked"), tpl, form) + } else { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_private_ip"), tpl, form) + } + case addrErr.IsInvalidPath: + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_local_path"), tpl, form) + default: + log.Error("Error whilst updating url: %v", err) + ctx.RenderWithErr(ctx.Tr("form.url_error"), tpl, form) + } + } else { + log.Error("Error whilst updating url: %v", err) + ctx.RenderWithErr(ctx.Tr("form.url_error"), tpl, form) + } +} + +// MigratePost response for migrating from external git repository +func MigratePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MigrateRepoForm) + if setting.Repository.DisableMigrations { + ctx.Error(http.StatusForbidden, "MigratePost: the site administrator has disabled migrations") + return + } + + serviceType := structs.GitServiceType(form.Service) + + setMigrationContextData(ctx, serviceType) + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + tpl := base.TplName("repo/migrate/" + serviceType.Name()) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tpl) + return + } + + remoteAddr, err := forms.ParseRemoteAddr(form.CloneAddr, form.AuthUsername, form.AuthPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(remoteAddr, ctx.User) + } + if err != nil { + ctx.Data["Err_CloneAddr"] = true + handleMigrateRemoteAddrError(ctx, err, tpl, form) + return + } + + form.LFS = form.LFS && setting.LFS.StartServer + + if form.LFS && len(form.LFSEndpoint) > 0 { + ep := lfs.DetermineEndpoint("", form.LFSEndpoint) + if ep == nil { + ctx.Data["Err_LFSEndpoint"] = true + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_lfs_endpoint"), tpl, &form) + return + } + err = migrations.IsMigrateURLAllowed(ep.String(), ctx.User) + if err != nil { + ctx.Data["Err_LFSEndpoint"] = true + handleMigrateRemoteAddrError(ctx, err, tpl, form) + return + } + } + + var opts = migrations.MigrateOptions{ + OriginalURL: form.CloneAddr, + GitServiceType: serviceType, + CloneAddr: remoteAddr, + RepoName: form.RepoName, + Description: form.Description, + Private: form.Private || setting.Repository.ForcePrivate, + Mirror: form.Mirror && !setting.Repository.DisableMirrors, + LFS: form.LFS, + LFSEndpoint: form.LFSEndpoint, + AuthUsername: form.AuthUsername, + AuthPassword: form.AuthPassword, + AuthToken: form.AuthToken, + Wiki: form.Wiki, + Issues: form.Issues, + Milestones: form.Milestones, + Labels: form.Labels, + Comments: form.Issues || form.PullRequests, + PullRequests: form.PullRequests, + Releases: form.Releases, + } + if opts.Mirror { + opts.Issues = false + opts.Milestones = false + opts.Labels = false + opts.Comments = false + opts.PullRequests = false + opts.Releases = false + } + + err = models.CheckCreateRepository(ctx.User, ctxUser, opts.RepoName, false) + if err != nil { + handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form) + return + } + + err = task.MigrateRepository(ctx.User, ctxUser, opts) + if err == nil { + ctx.Redirect(ctxUser.HomeLink() + "/" + opts.RepoName) + return + } + + handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form) +} + +func setMigrationContextData(ctx *context.Context, serviceType structs.GitServiceType) { + ctx.Data["Title"] = ctx.Tr("new_migrate") + + ctx.Data["LFSActive"] = setting.LFS.StartServer + ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate + ctx.Data["DisableMirrors"] = setting.Repository.DisableMirrors + + // Plain git should be first + ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedFullGitService...) + ctx.Data["service"] = serviceType +} diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go new file mode 100644 index 0000000000..bb6b310cbe --- /dev/null +++ b/routers/web/repo/milestone.go @@ -0,0 +1,299 @@ +// 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 repo + +import ( + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "xorm.io/builder" +) + +const ( + tplMilestone base.TplName = "repo/issue/milestones" + tplMilestoneNew base.TplName = "repo/issue/milestone_new" + tplMilestoneIssues base.TplName = "repo/issue/milestone_issues" +) + +// Milestones render milestones page +func Milestones(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.milestones") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsMilestones"] = true + + isShowClosed := ctx.Query("state") == "closed" + stats, err := models.GetMilestonesStatsByRepoCond(builder.And(builder.Eq{"id": ctx.Repo.Repository.ID})) + if err != nil { + ctx.ServerError("MilestoneStats", err) + return + } + ctx.Data["OpenCount"] = stats.OpenCount + ctx.Data["ClosedCount"] = stats.ClosedCount + + sortType := ctx.Query("sort") + + keyword := strings.Trim(ctx.Query("q"), " ") + + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + var total int + var state structs.StateType + if !isShowClosed { + total = int(stats.OpenCount) + state = structs.StateOpen + } else { + total = int(stats.ClosedCount) + state = structs.StateClosed + } + + miles, err := models.GetMilestones(models.GetMilestonesOption{ + ListOptions: models.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + RepoID: ctx.Repo.Repository.ID, + State: state, + SortType: sortType, + Name: keyword, + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + if ctx.Repo.Repository.IsTimetrackerEnabled() { + if err := miles.LoadTotalTrackedTimes(); err != nil { + ctx.ServerError("LoadTotalTrackedTimes", err) + return + } + } + for _, m := range miles { + m.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, m.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } + ctx.Data["Milestones"] = miles + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + ctx.Data["SortType"] = sortType + ctx.Data["Keyword"] = keyword + ctx.Data["IsShowClosed"] = isShowClosed + + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "state", "State") + pager.AddParam(ctx, "q", "Keyword") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplMilestone) +} + +// NewMilestone render creating milestone page +func NewMilestone(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.milestones.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsMilestones"] = true + ctx.HTML(http.StatusOK, tplMilestoneNew) +} + +// NewMilestonePost response for creating milestone +func NewMilestonePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateMilestoneForm) + ctx.Data["Title"] = ctx.Tr("repo.milestones.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsMilestones"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMilestoneNew) + return + } + + if len(form.Deadline) == 0 { + form.Deadline = "9999-12-31" + } + deadline, err := time.ParseInLocation("2006-01-02", form.Deadline, time.Local) + if err != nil { + ctx.Data["Err_Deadline"] = true + ctx.RenderWithErr(ctx.Tr("repo.milestones.invalid_due_date_format"), tplMilestoneNew, &form) + return + } + + deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location()) + if err = models.NewMilestone(&models.Milestone{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Content: form.Content, + DeadlineUnix: timeutil.TimeStamp(deadline.Unix()), + }); err != nil { + ctx.ServerError("NewMilestone", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.milestones.create_success", form.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/milestones") +} + +// EditMilestone render edting milestone page +func EditMilestone(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.milestones.edit") + ctx.Data["PageIsMilestones"] = true + ctx.Data["PageIsEditMilestone"] = true + + m, err := models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrMilestoneNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetMilestoneByRepoID", err) + } + return + } + ctx.Data["title"] = m.Name + ctx.Data["content"] = m.Content + if len(m.DeadlineString) > 0 { + ctx.Data["deadline"] = m.DeadlineString + } + ctx.HTML(http.StatusOK, tplMilestoneNew) +} + +// EditMilestonePost response for edting milestone +func EditMilestonePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateMilestoneForm) + ctx.Data["Title"] = ctx.Tr("repo.milestones.edit") + ctx.Data["PageIsMilestones"] = true + ctx.Data["PageIsEditMilestone"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMilestoneNew) + return + } + + if len(form.Deadline) == 0 { + form.Deadline = "9999-12-31" + } + deadline, err := time.ParseInLocation("2006-01-02", form.Deadline, time.Local) + if err != nil { + ctx.Data["Err_Deadline"] = true + ctx.RenderWithErr(ctx.Tr("repo.milestones.invalid_due_date_format"), tplMilestoneNew, &form) + return + } + + deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location()) + m, err := models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrMilestoneNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetMilestoneByRepoID", err) + } + return + } + m.Name = form.Title + m.Content = form.Content + m.DeadlineUnix = timeutil.TimeStamp(deadline.Unix()) + if err = models.UpdateMilestone(m, m.IsClosed); err != nil { + ctx.ServerError("UpdateMilestone", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.milestones.edit_success", m.Name)) + ctx.Redirect(ctx.Repo.RepoLink + "/milestones") +} + +// ChangeMilestoneStatus response for change a milestone's status +func ChangeMilestoneStatus(ctx *context.Context) { + toClose := false + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.Redirect(ctx.Repo.RepoLink + "/milestones") + } + id := ctx.ParamsInt64(":id") + + if err := models.ChangeMilestoneStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if models.IsErrMilestoneNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeMilestoneStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=" + ctx.Params(":action")) +} + +// DeleteMilestone delete a milestone +func DeleteMilestone(ctx *context.Context) { + if err := models.DeleteMilestoneByRepoID(ctx.Repo.Repository.ID, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/milestones", + }) +} + +// MilestoneIssuesAndPulls lists all the issues and pull requests of the milestone +func MilestoneIssuesAndPulls(ctx *context.Context) { + milestoneID := ctx.ParamsInt64(":id") + milestone, err := models.GetMilestoneByID(milestoneID) + if err != nil { + if models.IsErrMilestoneNotExist(err) { + ctx.NotFound("GetMilestoneByID", err) + return + } + + ctx.ServerError("GetMilestoneByID", err) + return + } + + milestone.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, milestone.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.Data["Title"] = milestone.Name + ctx.Data["Milestone"] = milestone + + issues(ctx, milestoneID, 0, util.OptionalBoolNone) + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + + ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) + ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) + + ctx.HTML(http.StatusOK, tplMilestoneIssues) +} diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go new file mode 100644 index 0000000000..eb0719995c --- /dev/null +++ b/routers/web/repo/projects.go @@ -0,0 +1,665 @@ +// Copyright 2020 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 repo + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplProjects base.TplName = "repo/projects/list" + tplProjectsNew base.TplName = "repo/projects/new" + tplProjectsView base.TplName = "repo/projects/view" + tplGenericProjectsNew base.TplName = "user/project" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if models.UnitTypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableKanbanBoard", nil) + return + } + + if ctx.Repo.Repository != nil { + if !ctx.Repo.CanRead(models.UnitTypeProjects) { + ctx.NotFound("MustEnableProjects", nil) + return + } + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.project_board") + + sortType := ctx.QueryTrim("sort") + + isShowClosed := strings.ToLower(ctx.QueryTrim("state")) == "closed" + repo := ctx.Repo.Repository + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + ctx.Data["OpenCount"] = repo.NumOpenProjects + ctx.Data["ClosedCount"] = repo.NumClosedProjects + + var total int + if !isShowClosed { + total = repo.NumOpenProjects + } else { + total = repo.NumClosedProjects + } + + projects, count, err := models.GetProjects(models.ProjectSearchOptions{ + RepoID: repo.ID, + Page: page, + IsClosed: util.OptionalBoolOf(isShowClosed), + SortType: sortType, + Type: models.ProjectTypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + for i := range projects { + projects[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, projects[i].Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } + + ctx.Data["Projects"] = projects + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + numPages := 0 + if count > 0 { + numPages = int((int(count) - 1) / setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["IsProjectsPage"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(http.StatusOK, tplProjects) +} + +// NewProject render creating a project page +func NewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = models.GetProjectsConfig() + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// NewProjectPost creates a new project +func NewProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + + if ctx.HasError() { + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + ctx.Data["ProjectTypes"] = models.GetProjectsConfig() + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + if err := models.NewProject(&models.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.User.ID, + BoardType: form.BoardType, + Type: models.ProjectTypeRepository, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.Context) { + toClose := false + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } + id := ctx.ParamsInt64(":id") + + if err := models.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeProjectStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + ctx.Params(":action")) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + if err := models.DeleteProjectByID(p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/projects", + }) +} + +// EditProject allows a project to be edited +func EditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsProjects"] = true + ctx.Data["PageIsEditProjects"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsProjects"] = true + ctx.Data["PageIsEditProjects"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + if err = models.UpdateProject(p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ViewProject renders the project board for a project +func ViewProject(ctx *context.Context) { + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + boards, err := models.GetProjectBoards(project.ID) + if err != nil { + ctx.ServerError("GetProjectBoards", err) + return + } + + if boards[0].ID == 0 { + boards[0].Title = ctx.Tr("repo.projects.type.uncategorized") + } + + issueList, err := boards.LoadIssues() + if err != nil { + ctx.ServerError("LoadIssuesOfBoards", err) + return + } + ctx.Data["Issues"] = issueList + + linkedPrsMap := make(map[int64][]*models.Issue) + for _, issue := range issueList { + var referencedIds []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIds = append(referencedIds, comment.RefIssueID) + } + } + + if len(referencedIds) > 0 { + if linkedPrs, err := models.Issues(&models.IssuesOptions{ + IssueIDs: referencedIds, + IsPull: util.OptionalBoolTrue, + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + ctx.Data["LinkedPRs"] = linkedPrsMap + + project.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, project.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + ctx.Data["Project"] = project + ctx.Data["Boards"] = boards + ctx.Data["PageIsProjects"] = true + ctx.Data["RequiresDraggable"] = true + + ctx.HTML(http.StatusOK, tplProjectsView) +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + projectID := ctx.QueryInt64("id") + for _, issue := range issues { + oldProjectID := issue.ProjectID() + if oldProjectID == projectID { + continue + } + + if err := models.ChangeProjectAssign(issue, ctx.User, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// DeleteProjectBoard allows for the deletion of a project board +func DeleteProjectBoard(ctx *context.Context) { + if ctx.User == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + pb, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.ServerError("GetProjectBoard", err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID), + }) + return + } + + if err := models.DeleteProjectBoardByID(ctx.ParamsInt64(":boardID")); err != nil { + ctx.ServerError("DeleteProjectBoardByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// AddBoardToProjectPost allows a new board to be added to a project. +func AddBoardToProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectBoardForm) + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + if err := models.NewProjectBoard(&models.ProjectBoard{ + ProjectID: project.ID, + Title: form.Title, + CreatorID: ctx.User.ID, + }); err != nil { + ctx.ServerError("NewProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +func checkProjectBoardChangePermissions(ctx *context.Context) (*models.Project, *models.ProjectBoard) { + if ctx.User == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + + board, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.ServerError("GetProjectBoard", err) + return nil, nil + } + if board.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), + }) + return nil, nil + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID), + }) + return nil, nil + } + return project, board +} + +// EditProjectBoard allows a project board's to be updated +func EditProjectBoard(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectBoardForm) + _, board := checkProjectBoardChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + board.Title = form.Title + } + + if form.Sorting != 0 { + board.Sorting = form.Sorting + } + + if err := models.UpdateProjectBoard(board); err != nil { + ctx.ServerError("UpdateProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// SetDefaultProjectBoard set default board for uncategorized issues/pulls +func SetDefaultProjectBoard(ctx *context.Context) { + + project, board := checkProjectBoardChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := models.SetDefaultBoard(project.ID, board.ID); err != nil { + ctx.ServerError("SetDefaultBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// MoveIssueAcrossBoards move a card from one board to another in a project +func MoveIssueAcrossBoards(ctx *context.Context) { + + if ctx.User == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + p, err := models.GetProjectByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + var board *models.ProjectBoard + + if ctx.ParamsInt64(":boardID") == 0 { + + board = &models.ProjectBoard{ + ID: 0, + ProjectID: 0, + Title: ctx.Tr("repo.projects.type.uncategorized"), + } + + } else { + board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID")) + if err != nil { + if models.IsErrProjectBoardNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + if board.ProjectID != p.ID { + ctx.NotFound("", nil) + return + } + } + + issue, err := models.GetIssueByID(ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetIssueByID", err) + } + + return + } + + if err := models.MoveIssueAcrossProjectBoards(issue, board); err != nil { + ctx.ServerError("MoveIssueAcrossProjectBoards", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// CreateProject renders the generic project creation page +func CreateProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = models.GetProjectsConfig() + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + + ctx.HTML(http.StatusOK, tplGenericProjectsNew) +} + +// CreateProjectPost creates an individual and/or organization project +func CreateProjectPost(ctx *context.Context, form forms.UserCreateProjectForm) { + + user := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + + ctx.Data["ContextUser"] = user + + if ctx.HasError() { + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects) + ctx.HTML(http.StatusOK, tplGenericProjectsNew) + return + } + + var projectType = models.ProjectTypeIndividual + if user.IsOrganization() { + projectType = models.ProjectTypeOrganization + } + + if err := models.NewProject(&models.Project{ + Title: form.Title, + Description: form.Content, + CreatorID: user.ID, + BoardType: form.BoardType, + Type: projectType, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/") +} diff --git a/routers/web/repo/projects_test.go b/routers/web/repo/projects_test.go new file mode 100644 index 0000000000..c43cf6d952 --- /dev/null +++ b/routers/web/repo/projects_test.go @@ -0,0 +1,28 @@ +// Copyright 2020 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 repo + +import ( + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestCheckProjectBoardChangePermissions(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/projects/1/2") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + ctx.SetParams(":id", "1") + ctx.SetParams(":boardID", "2") + + project, board := checkProjectBoardChangePermissions(ctx) + assert.NotNil(t, project) + assert.NotNil(t, board) + assert.False(t, ctx.Written()) +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go new file mode 100644 index 0000000000..28f94c8417 --- /dev/null +++ b/routers/web/repo/pull.go @@ -0,0 +1,1341 @@ +// Copyright 2018 The Gitea Authors. +// 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 repo + +import ( + "container/list" + "crypto/subtle" + "errors" + "fmt" + "net/http" + "path" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/notification" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/upload" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/gitdiff" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + "github.com/unknwon/com" +) + +const ( + tplFork base.TplName = "repo/pulls/fork" + tplCompareDiff base.TplName = "repo/diff/compare" + tplPullCommits base.TplName = "repo/pulls/commits" + tplPullFiles base.TplName = "repo/pulls/files" + + pullRequestTemplateKey = "PullRequestTemplate" +) + +var ( + pullRequestTemplateCandidates = []string{ + "PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", + ".gitea/PULL_REQUEST_TEMPLATE.md", + ".gitea/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.md", + } +) + +func getRepository(ctx *context.Context, repoID int64) *models.Repository { + repo, err := models.GetRepositoryByID(repoID) + if err != nil { + if models.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByID", nil) + } else { + ctx.ServerError("GetRepositoryByID", err) + } + return nil + } + + perm, err := models.GetUserRepoPermission(repo, ctx.User) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + + if !perm.CanRead(models.UnitTypeCode) { + log.Trace("Permission Denied: User %-v cannot read %-v of repo %-v\n"+ + "User in repo has Permissions: %-+v", + ctx.User, + models.UnitTypeCode, + ctx.Repo, + perm) + ctx.NotFound("getRepository", nil) + return nil + } + return repo +} + +func getForkRepository(ctx *context.Context) *models.Repository { + forkRepo := getRepository(ctx, ctx.ParamsInt64(":repoid")) + if ctx.Written() { + return nil + } + + if forkRepo.IsEmpty { + log.Trace("Empty repository %-v", forkRepo) + ctx.NotFound("getForkRepository", nil) + return nil + } + + if err := forkRepo.GetOwner(); err != nil { + ctx.ServerError("GetOwner", err) + return nil + } + + ctx.Data["repo_name"] = forkRepo.Name + ctx.Data["description"] = forkRepo.Description + ctx.Data["IsPrivate"] = forkRepo.IsPrivate || forkRepo.Owner.Visibility == structs.VisibleTypePrivate + canForkToUser := forkRepo.OwnerID != ctx.User.ID && !ctx.User.HasForkedRepo(forkRepo.ID) + + ctx.Data["ForkFrom"] = forkRepo.Owner.Name + "/" + forkRepo.Name + ctx.Data["ForkFromOwnerID"] = forkRepo.Owner.ID + + if err := ctx.User.GetOwnedOrganizations(); err != nil { + ctx.ServerError("GetOwnedOrganizations", err) + return nil + } + var orgs []*models.User + for _, org := range ctx.User.OwnedOrgs { + if forkRepo.OwnerID != org.ID && !org.HasForkedRepo(forkRepo.ID) { + orgs = append(orgs, org) + } + } + + var traverseParentRepo = forkRepo + var err error + for { + if ctx.User.ID == traverseParentRepo.OwnerID { + canForkToUser = false + } else { + for i, org := range orgs { + if org.ID == traverseParentRepo.OwnerID { + orgs = append(orgs[:i], orgs[i+1:]...) + break + } + } + } + + if !traverseParentRepo.IsFork { + break + } + traverseParentRepo, err = models.GetRepositoryByID(traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return nil + } + } + + ctx.Data["CanForkToUser"] = canForkToUser + ctx.Data["Orgs"] = orgs + + if canForkToUser { + ctx.Data["ContextUser"] = ctx.User + } else if len(orgs) > 0 { + ctx.Data["ContextUser"] = orgs[0] + } + + return forkRepo +} + +// Fork render repository fork page +func Fork(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_fork") + + getForkRepository(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplFork) +} + +// ForkPost response for forking a repository +func ForkPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateRepoForm) + ctx.Data["Title"] = ctx.Tr("new_fork") + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + + forkRepo := getForkRepository(ctx) + if ctx.Written() { + return + } + + ctx.Data["ContextUser"] = ctxUser + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplFork) + return + } + + var err error + var traverseParentRepo = forkRepo + for { + if ctxUser.ID == traverseParentRepo.OwnerID { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + return + } + repo, has := models.HasForkedRepo(ctxUser.ID, traverseParentRepo.ID) + if has { + ctx.Redirect(ctxUser.HomeLink() + "/" + repo.Name) + return + } + if !traverseParentRepo.IsFork { + break + } + traverseParentRepo, err = models.GetRepositoryByID(traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + } + + // Check ownership of organization. + if ctxUser.IsOrganization() { + isOwner, err := ctxUser.IsOwnedBy(ctx.User.ID) + if err != nil { + ctx.ServerError("IsOwnedBy", err) + return + } else if !isOwner { + ctx.Error(http.StatusForbidden) + return + } + } + + repo, err := repo_service.ForkRepository(ctx.User, ctxUser, forkRepo, form.RepoName, form.Description) + if err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case models.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + case models.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tplFork, &form) + case models.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tplFork, &form) + default: + ctx.ServerError("ForkPost", err) + } + return + } + + log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(ctxUser.HomeLink() + "/" + repo.Name) +} + +func checkPullInfo(ctx *context.Context) *models.Issue { + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return nil + } + if err = issue.LoadPoster(); err != nil { + ctx.ServerError("LoadPoster", err) + return nil + } + if err := issue.LoadRepo(); err != nil { + ctx.ServerError("LoadRepo", err) + return nil + } + ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title) + ctx.Data["Issue"] = issue + + if !issue.IsPull { + ctx.NotFound("ViewPullCommits", nil) + return nil + } + + if err = issue.LoadPullRequest(); err != nil { + ctx.ServerError("LoadPullRequest", err) + return nil + } + + if err = issue.PullRequest.LoadHeadRepo(); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return nil + } + + if ctx.IsSigned { + // Update issue-user. + if err = issue.ReadBy(ctx.User.ID); err != nil { + ctx.ServerError("ReadBy", err) + return nil + } + } + + return issue +} + +func setMergeTarget(ctx *context.Context, pull *models.PullRequest) { + if ctx.Repo.Owner.Name == pull.MustHeadUserName() { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = pull.MustHeadUserName() + ":" + pull.HeadBranch + } else { + ctx.Data["HeadTarget"] = pull.MustHeadUserName() + "/" + pull.HeadRepo.Name + ":" + pull.HeadBranch + } + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["HeadBranchHTMLURL"] = pull.GetHeadBranchHTMLURL() + ctx.Data["BaseBranchHTMLURL"] = pull.GetBaseBranchHTMLURL() +} + +// PrepareMergedViewPullInfo show meta information for a merged pull request view page +func PrepareMergedViewPullInfo(ctx *context.Context, issue *models.Issue) *git.CompareInfo { + pull := issue.PullRequest + + setMergeTarget(ctx, pull) + ctx.Data["HasMerged"] = true + + compareInfo, err := ctx.Repo.GitRepo.GetCompareInfo(ctx.Repo.Repository.RepoPath(), + pull.MergeBase, pull.GetGitRefName()) + if err != nil { + if strings.Contains(err.Error(), "fatal: Not a valid object name") || strings.Contains(err.Error(), "unknown revision or path not in the working tree") { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + + ctx.ServerError("GetCompareInfo", err) + return nil + } + ctx.Data["NumCommits"] = compareInfo.Commits.Len() + ctx.Data["NumFiles"] = compareInfo.NumFiles + + if compareInfo.Commits.Len() != 0 { + sha := compareInfo.Commits.Front().Value.(*git.Commit).ID.String() + commitStatuses, err := models.GetLatestCommitStatus(ctx.Repo.Repository.ID, sha, models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if len(commitStatuses) != 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = models.CalcCommitStatus(commitStatuses) + } + } + + return compareInfo +} + +// PrepareViewPullInfo show meta information for a pull request preview page +func PrepareViewPullInfo(ctx *context.Context, issue *models.Issue) *git.CompareInfo { + repo := ctx.Repo.Repository + pull := issue.PullRequest + + if err := pull.LoadHeadRepo(); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return nil + } + + if err := pull.LoadBaseRepo(); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return nil + } + + setMergeTarget(ctx, pull) + + if err := pull.LoadProtectedBranch(); err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return nil + } + ctx.Data["EnableStatusCheck"] = pull.ProtectedBranch != nil && pull.ProtectedBranch.EnableStatusCheck + + baseGitRepo, err := git.OpenRepository(pull.BaseRepo.RepoPath()) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer baseGitRepo.Close() + + if !baseGitRepo.IsBranchExist(pull.BaseBranch) { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["HeadTarget"] = pull.HeadBranch + + sha, err := baseGitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitRefName()), err) + return nil + } + commitStatuses, err := models.GetLatestCommitStatus(repo.ID, sha, models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if len(commitStatuses) > 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = models.CalcCommitStatus(commitStatuses) + } + + compareInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), + pull.MergeBase, pull.GetGitRefName()) + if err != nil { + if strings.Contains(err.Error(), "fatal: Not a valid object name") { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + + ctx.ServerError("GetCompareInfo", err) + return nil + } + + ctx.Data["NumCommits"] = compareInfo.Commits.Len() + ctx.Data["NumFiles"] = compareInfo.NumFiles + return compareInfo + } + + var headBranchExist bool + var headBranchSha string + // HeadRepo may be missing + if pull.HeadRepo != nil { + headGitRepo, err := git.OpenRepository(pull.HeadRepo.RepoPath()) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer headGitRepo.Close() + + headBranchExist = headGitRepo.IsBranchExist(pull.HeadBranch) + + if headBranchExist { + headBranchSha, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) + if err != nil { + ctx.ServerError("GetBranchCommitID", err) + return nil + } + } + } + + if headBranchExist { + ctx.Data["UpdateAllowed"], err = pull_service.IsUserAllowedToUpdate(pull, ctx.User) + if err != nil { + ctx.ServerError("IsUserAllowedToUpdate", err) + return nil + } + ctx.Data["GetCommitMessages"] = pull_service.GetSquashMergeCommitMessages(pull) + } + + sha, err := baseGitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + if git.IsErrNotExist(err) { + ctx.Data["IsPullRequestBroken"] = true + if pull.IsSameRepo() { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = "<deleted>:" + pull.HeadBranch + } else { + ctx.Data["HeadTarget"] = pull.HeadRepo.OwnerName + ":" + pull.HeadBranch + } + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitRefName()), err) + return nil + } + + commitStatuses, err := models.GetLatestCommitStatus(repo.ID, sha, models.ListOptions{}) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if len(commitStatuses) > 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = models.CalcCommitStatus(commitStatuses) + } + + if pull.ProtectedBranch != nil && pull.ProtectedBranch.EnableStatusCheck { + ctx.Data["is_context_required"] = func(context string) bool { + for _, c := range pull.ProtectedBranch.StatusCheckContexts { + if c == context { + return true + } + } + return false + } + ctx.Data["RequiredStatusCheckState"] = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, pull.ProtectedBranch.StatusCheckContexts) + } + + ctx.Data["HeadBranchMovedOn"] = headBranchSha != sha + ctx.Data["HeadBranchCommitID"] = headBranchSha + ctx.Data["PullHeadCommitID"] = sha + + if pull.HeadRepo == nil || !headBranchExist || headBranchSha != sha { + ctx.Data["IsPullRequestBroken"] = true + if pull.IsSameRepo() { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = "<deleted>:" + pull.HeadBranch + } else { + ctx.Data["HeadTarget"] = pull.HeadRepo.OwnerName + ":" + pull.HeadBranch + } + } + + compareInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), + git.BranchPrefix+pull.BaseBranch, pull.GetGitRefName()) + if err != nil { + if strings.Contains(err.Error(), "fatal: Not a valid object name") { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + + ctx.ServerError("GetCompareInfo", err) + return nil + } + + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + + if pull.IsWorkInProgress() { + ctx.Data["IsPullWorkInProgress"] = true + ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix() + } + + if pull.IsFilesConflicted() { + ctx.Data["IsPullFilesConflicted"] = true + ctx.Data["ConflictedFiles"] = pull.ConflictedFiles + } + + ctx.Data["NumCommits"] = compareInfo.Commits.Len() + ctx.Data["NumFiles"] = compareInfo.NumFiles + return compareInfo +} + +// ViewPullCommits show commits for a pull request +func ViewPullCommits(ctx *context.Context) { + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullCommits"] = true + + issue := checkPullInfo(ctx) + if ctx.Written() { + return + } + pull := issue.PullRequest + + var commits *list.List + var prInfo *git.CompareInfo + if pull.HasMerged { + prInfo = PrepareMergedViewPullInfo(ctx, issue) + } else { + prInfo = PrepareViewPullInfo(ctx, issue) + } + + if ctx.Written() { + return + } else if prInfo == nil { + ctx.NotFound("ViewPullCommits", nil) + return + } + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + commits = prInfo.Commits + commits = models.ValidateCommitsWithEmails(commits) + commits = models.ParseCommitsWithSignature(commits, ctx.Repo.Repository) + commits = models.ParseCommitsWithStatus(commits, ctx.Repo.Repository) + ctx.Data["Commits"] = commits + ctx.Data["CommitCount"] = commits.Len() + + getBranchData(ctx, issue) + ctx.HTML(http.StatusOK, tplPullCommits) +} + +// ViewPullFiles render pull request changed files list page +func ViewPullFiles(ctx *context.Context) { + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullFiles"] = true + + issue := checkPullInfo(ctx) + if ctx.Written() { + return + } + pull := issue.PullRequest + + var ( + diffRepoPath string + startCommitID string + endCommitID string + gitRepo *git.Repository + ) + + var prInfo *git.CompareInfo + if pull.HasMerged { + prInfo = PrepareMergedViewPullInfo(ctx, issue) + } else { + prInfo = PrepareViewPullInfo(ctx, issue) + } + + if ctx.Written() { + return + } else if prInfo == nil { + ctx.NotFound("ViewPullFiles", nil) + return + } + + diffRepoPath = ctx.Repo.GitRepo.Path + gitRepo = ctx.Repo.GitRepo + + headCommitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + + startCommitID = prInfo.MergeBase + endCommitID = headCommitID + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["AfterCommitID"] = endCommitID + + diff, err := gitdiff.GetDiffRangeWithWhitespaceBehavior(diffRepoPath, + startCommitID, endCommitID, setting.Git.MaxGitDiffLines, + setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, + gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + if err != nil { + ctx.ServerError("GetDiffRangeWithWhitespaceBehavior", err) + return + } + + if err = diff.LoadComments(issue, ctx.User); err != nil { + ctx.ServerError("LoadComments", err) + return + } + + if err = pull.LoadProtectedBranch(); err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return + } + + if pull.ProtectedBranch != nil { + glob := pull.ProtectedBranch.GetProtectedFilePatterns() + if len(glob) != 0 { + for _, file := range diff.Files { + file.IsProtected = pull.ProtectedBranch.IsProtectedFile(glob, file.Name) + } + } + } + + ctx.Data["Diff"] = diff + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + baseCommit, err := ctx.Repo.GitRepo.GetCommit(startCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return + } + commit, err := gitRepo.GetCommit(endCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return + } + + if ctx.IsSigned && ctx.User != nil { + if ctx.Data["CanMarkConversation"], err = models.CanMarkConversation(issue, ctx.User); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + } + + headTarget := path.Join(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + setCompareContext(ctx, baseCommit, commit, headTarget) + + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["RequireTribute"] = true + if ctx.Data["Assignees"], err = ctx.Repo.Repository.GetAssignees(); err != nil { + ctx.ServerError("GetAssignees", err) + return + } + handleTeamMentions(ctx) + if ctx.Written() { + return + } + ctx.Data["CurrentReview"], err = models.GetCurrentReview(ctx.User, issue) + if err != nil && !models.IsErrReviewNotExist(err) { + ctx.ServerError("GetCurrentReview", err) + return + } + getBranchData(ctx, issue) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.HTML(http.StatusOK, tplPullFiles) +} + +// UpdatePullRequest merge PR's baseBranch into headBranch +func UpdatePullRequest(ctx *context.Context) { + issue := checkPullInfo(ctx) + if ctx.Written() { + return + } + if issue.IsClosed { + ctx.NotFound("MergePullRequest", nil) + return + } + if issue.PullRequest.HasMerged { + ctx.NotFound("MergePullRequest", nil) + return + } + + if err := issue.PullRequest.LoadBaseRepo(); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return + } + if err := issue.PullRequest.LoadHeadRepo(); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return + } + + allowedUpdate, err := pull_service.IsUserAllowedToUpdate(issue.PullRequest, ctx.User) + if err != nil { + ctx.ServerError("IsUserAllowedToMerge", err) + return + } + + // ToDo: add check if maintainers are allowed to change branch ... (need migration & co) + if !allowedUpdate { + ctx.Flash.Error(ctx.Tr("repo.pulls.update_not_allowed")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) + return + } + + // default merge commit message + message := fmt.Sprintf("Merge branch '%s' into %s", issue.PullRequest.BaseBranch, issue.PullRequest.HeadBranch) + + if err = pull_service.Update(issue.PullRequest, ctx.User, message); err != nil { + if models.IsErrMergeConflicts(err) { + conflictError := err.(models.ErrMergeConflicts) + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.pulls.merge_conflict"), + "Summary": ctx.Tr("repo.pulls.merge_conflict_summary"), + "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "<br>" + utils.SanitizeFlashErrorString(conflictError.StdOut), + }) + if err != nil { + ctx.ServerError("UpdatePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) + return + } + ctx.Flash.Error(err.Error()) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) + return + } + + time.Sleep(1 * time.Second) + + ctx.Flash.Success(ctx.Tr("repo.pulls.update_branch_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) +} + +// MergePullRequest response for merging pull request +func MergePullRequest(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MergePullRequestForm) + issue := checkPullInfo(ctx) + if ctx.Written() { + return + } + if issue.IsClosed { + if issue.IsPull { + ctx.Flash.Error(ctx.Tr("repo.pulls.is_closed")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) + return + } + ctx.Flash.Error(ctx.Tr("repo.issues.closed_title")) + ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + fmt.Sprint(issue.Index)) + return + } + + pr := issue.PullRequest + + allowedMerge, err := pull_service.IsUserAllowedToMerge(pr, ctx.Repo.Permission, ctx.User) + if err != nil { + ctx.ServerError("IsUserAllowedToMerge", err) + return + } + if !allowedMerge { + ctx.Flash.Error(ctx.Tr("repo.pulls.update_not_allowed")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(issue.Index)) + return + } + + if pr.HasMerged { + ctx.Flash.Error(ctx.Tr("repo.pulls.has_merged")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) + return + } + + // handle manually-merged mark + if models.MergeStyle(form.Do) == models.MergeStyleManuallyMerged { + if err = pull_service.MergedManually(pr, ctx.User, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { + if models.IsErrInvalidMergeStyle(err) { + ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) + return + } else if strings.Contains(err.Error(), "Wrong commit ID") { + ctx.Flash.Error(ctx.Tr("repo.pulls.wrong_commit_id")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) + return + } + + ctx.ServerError("MergedManually", err) + return + } + + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) + return + } + + if !pr.CanAutoMerge() { + ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_not_ready")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) + return + } + + if pr.IsWorkInProgress() { + ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_wip")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } + + if err := pull_service.CheckPRReadyToMerge(pr, false); err != nil { + if !models.IsErrNotAllowedToMerge(err) { + ctx.ServerError("Merge PR status", err) + return + } + if isRepoAdmin, err := models.IsUserRepoAdmin(pr.BaseRepo, ctx.User); err != nil { + ctx.ServerError("IsUserRepoAdmin", err) + return + } else if !isRepoAdmin { + ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_not_ready")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } + + message := strings.TrimSpace(form.MergeTitleField) + if len(message) == 0 { + if models.MergeStyle(form.Do) == models.MergeStyleMerge { + message = pr.GetDefaultMergeMessage() + } + if models.MergeStyle(form.Do) == models.MergeStyleRebaseMerge { + message = pr.GetDefaultMergeMessage() + } + if models.MergeStyle(form.Do) == models.MergeStyleSquash { + message = pr.GetDefaultSquashMessage() + } + } + + form.MergeMessageField = strings.TrimSpace(form.MergeMessageField) + if len(form.MergeMessageField) > 0 { + message += "\n\n" + form.MergeMessageField + } + + pr.Issue = issue + pr.Issue.Repo = ctx.Repo.Repository + + noDeps, err := models.IssueNoDependenciesLeft(issue) + if err != nil { + return + } + + if !noDeps { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } + + if err = pull_service.Merge(pr, ctx.User, ctx.Repo.GitRepo, models.MergeStyle(form.Do), message); err != nil { + if models.IsErrInvalidMergeStyle(err) { + ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } else if models.IsErrMergeConflicts(err) { + conflictError := err.(models.ErrMergeConflicts) + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.editor.merge_conflict"), + "Summary": ctx.Tr("repo.editor.merge_conflict_summary"), + "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "<br>" + utils.SanitizeFlashErrorString(conflictError.StdOut), + }) + if err != nil { + ctx.ServerError("MergePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } else if models.IsErrRebaseConflicts(err) { + conflictError := err.(models.ErrRebaseConflicts) + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.SanitizeFlashErrorString(conflictError.CommitSHA)), + "Summary": ctx.Tr("repo.pulls.rebase_conflict_summary"), + "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "<br>" + utils.SanitizeFlashErrorString(conflictError.StdOut), + }) + if err != nil { + ctx.ServerError("MergePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } else if models.IsErrMergeUnrelatedHistories(err) { + log.Debug("MergeUnrelatedHistories error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.unrelated_histories")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } else if git.IsErrPushOutOfDate(err) { + log.Debug("MergePushOutOfDate error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.merge_out_of_date")) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } else if git.IsErrPushRejected(err) { + log.Debug("MergePushRejected error: %v", err) + pushrejErr := err.(*git.ErrPushRejected) + message := pushrejErr.Message + if len(message) == 0 { + ctx.Flash.Error(ctx.Tr("repo.pulls.push_rejected_no_message")) + } else { + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.pulls.push_rejected"), + "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), + }) + if err != nil { + ctx.ServerError("MergePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + } + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) + return + } + ctx.ServerError("Merge", err) + return + } + + if err := stopTimerIfAvailable(ctx.User, issue); err != nil { + ctx.ServerError("CreateOrStopIssueStopwatch", err) + return + } + + log.Trace("Pull request merged: %d", pr.ID) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pr.Index)) +} + +func stopTimerIfAvailable(user *models.User, issue *models.Issue) error { + + if models.StopwatchExists(user.ID, issue.ID) { + if err := models.CreateOrStopIssueStopwatch(user, issue); err != nil { + return err + } + } + + return nil +} + +// CompareAndPullRequestPost response for creating pull request +func CompareAndPullRequestPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateIssueForm) + ctx.Data["Title"] = ctx.Tr("repo.pulls.compare_changes") + ctx.Data["PageIsComparePull"] = true + ctx.Data["IsDiffCompare"] = true + ctx.Data["RequireHighlightJS"] = true + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + var ( + repo = ctx.Repo.Repository + attachments []string + ) + + headUser, headRepo, headGitRepo, prInfo, baseBranch, headBranch := ParseCompareInfo(ctx) + if ctx.Written() { + return + } + defer headGitRepo.Close() + + labelIDs, assigneeIDs, milestoneID, _ := ValidateRepoMetas(ctx, *form, true) + if ctx.Written() { + return + } + + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + middleware.AssignForm(form, ctx.Data) + + // This stage is already stop creating new pull request, so it does not matter if it has + // something to compare or not. + PrepareCompareDiff(ctx, headUser, headRepo, headGitRepo, prInfo, baseBranch, headBranch, + gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplCompareDiff) + return + } + + if util.IsEmptyString(form.Title) { + PrepareCompareDiff(ctx, headUser, headRepo, headGitRepo, prInfo, baseBranch, headBranch, + gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + if ctx.Written() { + return + } + + ctx.RenderWithErr(ctx.Tr("repo.issues.new.title_empty"), tplCompareDiff, form) + return + } + + pullIssue := &models.Issue{ + RepoID: repo.ID, + Title: form.Title, + PosterID: ctx.User.ID, + Poster: ctx.User, + MilestoneID: milestoneID, + IsPull: true, + Content: form.Content, + } + pullRequest := &models.PullRequest{ + HeadRepoID: headRepo.ID, + BaseRepoID: repo.ID, + HeadBranch: headBranch, + BaseBranch: baseBranch, + HeadRepo: headRepo, + BaseRepo: repo, + MergeBase: prInfo.MergeBase, + Type: models.PullRequestGitea, + } + // FIXME: check error in the case two people send pull request at almost same time, give nice error prompt + // instead of 500. + + if err := pull_service.NewPullRequest(repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { + if models.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + return + } else if git.IsErrPushRejected(err) { + pushrejErr := err.(*git.ErrPushRejected) + message := pushrejErr.Message + if len(message) == 0 { + ctx.Flash.Error(ctx.Tr("repo.pulls.push_rejected_no_message")) + } else { + flashError, err := ctx.HTMLString(string(tplAlertDetails), map[string]interface{}{ + "Message": ctx.Tr("repo.pulls.push_rejected"), + "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), + }) + if err != nil { + ctx.ServerError("CompareAndPullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + } + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pullIssue.Index)) + return + } + ctx.ServerError("NewPullRequest", err) + return + } + + log.Trace("Pull request created: %d/%d", repo.ID, pullIssue.ID) + ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + fmt.Sprint(pullIssue.Index)) +} + +// TriggerTask response for a trigger task request +func TriggerTask(ctx *context.Context) { + pusherID := ctx.QueryInt64("pusher") + branch := ctx.Query("branch") + secret := ctx.Query("secret") + if len(branch) == 0 || len(secret) == 0 || pusherID <= 0 { + ctx.Error(http.StatusNotFound) + log.Trace("TriggerTask: branch or secret is empty, or pusher ID is not valid") + return + } + owner, repo := parseOwnerAndRepo(ctx) + if ctx.Written() { + return + } + got := []byte(base.EncodeMD5(owner.Salt)) + want := []byte(secret) + if subtle.ConstantTimeCompare(got, want) != 1 { + ctx.Error(http.StatusNotFound) + log.Trace("TriggerTask [%s/%s]: invalid secret", owner.Name, repo.Name) + return + } + + pusher, err := models.GetUserByID(pusherID) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.ServerError("GetUserByID", err) + } + return + } + + log.Trace("TriggerTask '%s/%s' by %s", repo.Name, branch, pusher.Name) + + go pull_service.AddTestPullRequestTask(pusher, repo.ID, branch, true, "", "") + ctx.Status(202) +} + +// CleanUpPullRequest responses for delete merged branch when PR has been merged +func CleanUpPullRequest(ctx *context.Context) { + issue := checkPullInfo(ctx) + if ctx.Written() { + return + } + + pr := issue.PullRequest + + // Don't cleanup unmerged and unclosed PRs + if !pr.HasMerged && !issue.IsClosed { + ctx.NotFound("CleanUpPullRequest", nil) + return + } + + if err := pr.LoadHeadRepo(); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return + } else if pr.HeadRepo == nil { + // Forked repository has already been deleted + ctx.NotFound("CleanUpPullRequest", nil) + return + } else if err = pr.LoadBaseRepo(); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return + } else if err = pr.HeadRepo.GetOwner(); err != nil { + ctx.ServerError("HeadRepo.GetOwner", err) + return + } + + perm, err := models.GetUserRepoPermission(pr.HeadRepo, ctx.User) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !perm.CanWrite(models.UnitTypeCode) { + ctx.NotFound("CleanUpPullRequest", nil) + return + } + + fullBranchName := pr.HeadRepo.Owner.Name + "/" + pr.HeadBranch + + gitRepo, err := git.OpenRepository(pr.HeadRepo.RepoPath()) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.RepoPath()), err) + return + } + defer gitRepo.Close() + + gitBaseRepo, err := git.OpenRepository(pr.BaseRepo.RepoPath()) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.BaseRepo.RepoPath()), err) + return + } + defer gitBaseRepo.Close() + + defer func() { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": pr.BaseRepo.Link() + "/pulls/" + fmt.Sprint(issue.Index), + }) + }() + + // Check if branch has no new commits + headCommitID, err := gitBaseRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + log.Error("GetRefCommitID: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + return + } + branchCommitID, err := gitRepo.GetBranchCommitID(pr.HeadBranch) + if err != nil { + log.Error("GetBranchCommitID: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + return + } + if headCommitID != branchCommitID { + ctx.Flash.Error(ctx.Tr("repo.branch.delete_branch_has_new_commits", fullBranchName)) + return + } + + if err := repo_service.DeleteBranch(ctx.User, pr.HeadRepo, gitRepo, pr.HeadBranch); err != nil { + switch { + case git.IsErrBranchNotExist(err): + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + case errors.Is(err, repo_service.ErrBranchIsDefault): + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + case errors.Is(err, repo_service.ErrBranchIsProtected): + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + default: + log.Error("DeleteBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + } + return + } + + if err := models.AddDeletePRBranchComment(ctx.User, pr.BaseRepo, issue.ID, pr.HeadBranch); err != nil { + // Do not fail here as branch has already been deleted + log.Error("DeleteBranch: %v", err) + } + + ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", fullBranchName)) +} + +// DownloadPullDiff render a pull's raw diff +func DownloadPullDiff(ctx *context.Context) { + DownloadPullDiffOrPatch(ctx, false) +} + +// DownloadPullPatch render a pull's raw patch +func DownloadPullPatch(ctx *context.Context) { + DownloadPullDiffOrPatch(ctx, true) +} + +// DownloadPullDiffOrPatch render a pull's raw diff or patch +func DownloadPullDiffOrPatch(ctx *context.Context, patch bool) { + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + + // Return not found if it's not a pull request + if !issue.IsPull { + ctx.NotFound("DownloadPullDiff", + fmt.Errorf("Issue is not a pull request")) + return + } + + if err = issue.LoadPullRequest(); err != nil { + ctx.ServerError("LoadPullRequest", err) + return + } + + pr := issue.PullRequest + + if err := pull_service.DownloadDiffOrPatch(pr, ctx, patch); err != nil { + ctx.ServerError("DownloadDiffOrPatch", err) + return + } +} + +// UpdatePullRequestTarget change pull request's target branch +func UpdatePullRequestTarget(ctx *context.Context) { + issue := GetActionIssue(ctx) + pr := issue.PullRequest + if ctx.Written() { + return + } + if !issue.IsPull { + ctx.Error(http.StatusNotFound) + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.User.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + targetBranch := ctx.QueryTrim("target_branch") + if len(targetBranch) == 0 { + ctx.Error(http.StatusNoContent) + return + } + + if err := pull_service.ChangeTargetBranch(pr, ctx.User, targetBranch); err != nil { + if models.IsErrPullRequestAlreadyExists(err) { + err := err.(models.ErrPullRequestAlreadyExists) + + RepoRelPath := ctx.Repo.Owner.Name + "/" + ctx.Repo.Repository.Name + errorMessage := ctx.Tr("repo.pulls.has_pull_request", ctx.Repo.RepoLink, RepoRelPath, err.IssueID) + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]interface{}{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if models.IsErrIssueIsClosed(err) { + errorMessage := ctx.Tr("repo.pulls.is_closed") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]interface{}{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if models.IsErrPullRequestHasMerged(err) { + errorMessage := ctx.Tr("repo.pulls.has_merged") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]interface{}{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if models.IsErrBranchesEqual(err) { + errorMessage := ctx.Tr("repo.pulls.nothing_to_compare") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusBadRequest, map[string]interface{}{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else { + ctx.ServerError("UpdatePullRequestTarget", err) + } + return + } + notification.NotifyPullRequestChangeTargetBranch(ctx.User, pr, targetBranch) + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "base_branch": pr.BaseBranch, + }) +} diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go new file mode 100644 index 0000000000..9e505c3db3 --- /dev/null +++ b/routers/web/repo/pull_review.go @@ -0,0 +1,238 @@ +// 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 repo + +import ( + "fmt" + "net/http" + + "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/web" + "code.gitea.io/gitea/services/forms" + pull_service "code.gitea.io/gitea/services/pull" +) + +const ( + tplConversation base.TplName = "repo/diff/conversation" + tplNewComment base.TplName = "repo/diff/new_comment" +) + +// RenderNewCodeCommentForm will render the form for creating a new review comment +func RenderNewCodeCommentForm(ctx *context.Context) { + issue := GetActionIssue(ctx) + if !issue.IsPull { + return + } + currentReview, err := models.GetCurrentReview(ctx.User, issue) + if err != nil && !models.IsErrReviewNotExist(err) { + ctx.ServerError("GetCurrentReview", err) + return + } + ctx.Data["PageIsPullFiles"] = true + ctx.Data["Issue"] = issue + ctx.Data["CurrentReview"] = currentReview + pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(issue.PullRequest.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + ctx.Data["AfterCommitID"] = pullHeadCommitID + ctx.HTML(http.StatusOK, tplNewComment) +} + +// CreateCodeComment will create a code comment including an pending review if required +func CreateCodeComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CodeCommentForm) + issue := GetActionIssue(ctx) + if !issue.IsPull { + return + } + if ctx.Written() { + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + + signedLine := form.Line + if form.Side == "previous" { + signedLine *= -1 + } + + comment, err := pull_service.CreateCodeComment( + ctx.User, + ctx.Repo.GitRepo, + issue, + signedLine, + form.Content, + form.TreePath, + form.IsReview, + form.Reply, + form.LatestCommitID, + ) + if err != nil { + ctx.ServerError("CreateCodeComment", err) + return + } + + if comment == nil { + log.Trace("Comment not created: %-v #%d[%d]", ctx.Repo.Repository, issue.Index, issue.ID) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + + log.Trace("Comment created: %-v #%d[%d] Comment[%d]", ctx.Repo.Repository, issue.Index, issue.ID, comment.ID) + + if form.Origin == "diff" { + renderConversation(ctx, comment) + return + } + ctx.Redirect(comment.HTMLURL()) +} + +// UpdateResolveConversation add or remove an Conversation resolved mark +func UpdateResolveConversation(ctx *context.Context) { + origin := ctx.Query("origin") + action := ctx.Query("action") + commentID := ctx.QueryInt64("comment_id") + + comment, err := models.GetCommentByID(commentID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if err = comment.LoadIssue(); err != nil { + ctx.ServerError("comment.LoadIssue", err) + return + } + + var permResult bool + if permResult, err = models.CanMarkConversation(comment.Issue, ctx.User); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + if !permResult { + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Issue.IsPull { + ctx.Error(http.StatusBadRequest) + return + } + + if action == "Resolve" || action == "UnResolve" { + err = models.MarkConversation(comment, ctx.User, action == "Resolve") + if err != nil { + ctx.ServerError("MarkConversation", err) + return + } + } else { + ctx.Error(http.StatusBadRequest) + return + } + + if origin == "diff" { + renderConversation(ctx, comment) + return + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +func renderConversation(ctx *context.Context, comment *models.Comment) { + comments, err := models.FetchCodeCommentsByLine(comment.Issue, ctx.User, comment.TreePath, comment.Line) + if err != nil { + ctx.ServerError("FetchCodeCommentsByLine", err) + return + } + ctx.Data["PageIsPullFiles"] = true + ctx.Data["comments"] = comments + ctx.Data["CanMarkConversation"] = true + ctx.Data["Issue"] = comment.Issue + if err = comment.Issue.LoadPullRequest(); err != nil { + ctx.ServerError("comment.Issue.LoadPullRequest", err) + return + } + pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(comment.Issue.PullRequest.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + ctx.Data["AfterCommitID"] = pullHeadCommitID + ctx.HTML(http.StatusOK, tplConversation) +} + +// SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist +func SubmitReview(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.SubmitReviewForm) + issue := GetActionIssue(ctx) + if !issue.IsPull { + return + } + if ctx.Written() { + return + } + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + + reviewType := form.ReviewType() + switch reviewType { + case models.ReviewTypeUnknown: + ctx.ServerError("ReviewType", fmt.Errorf("unknown ReviewType: %s", form.Type)) + return + + // can not approve/reject your own PR + case models.ReviewTypeApprove, models.ReviewTypeReject: + if issue.IsPoster(ctx.User.ID) { + var translated string + if reviewType == models.ReviewTypeApprove { + translated = ctx.Tr("repo.issues.review.self.approval") + } else { + translated = ctx.Tr("repo.issues.review.self.rejection") + } + + ctx.Flash.Error(translated) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + } + + _, comm, err := pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, issue, reviewType, form.Content, form.CommitID) + if err != nil { + if models.IsContentEmptyErr(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty")) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + } else { + ctx.ServerError("SubmitReview", err) + } + return + } + + ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, issue.Index, comm.HashTag())) +} + +// DismissReview dismissing stale review by repo admin +func DismissReview(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.DismissReviewForm) + comm, err := pull_service.DismissReview(form.ReviewID, form.Message, ctx.User, true) + if err != nil { + ctx.ServerError("pull_service.DismissReview", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, comm.Issue.Index, comm.HashTag())) +} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go new file mode 100644 index 0000000000..b7730e4ee2 --- /dev/null +++ b/routers/web/repo/release.go @@ -0,0 +1,512 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 repo + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/upload" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + releaseservice "code.gitea.io/gitea/services/release" +) + +const ( + tplReleases base.TplName = "repo/release/list" + tplReleaseNew base.TplName = "repo/release/new" +) + +// calReleaseNumCommitsBehind calculates given release has how many commits behind release target. +func calReleaseNumCommitsBehind(repoCtx *context.Repository, release *models.Release, countCache map[string]int64) error { + // Fast return if release target is same as default branch. + if repoCtx.BranchName == release.Target { + release.NumCommitsBehind = repoCtx.CommitsCount - release.NumCommits + return nil + } + + // Get count if not exists + if _, ok := countCache[release.Target]; !ok { + if repoCtx.GitRepo.IsBranchExist(release.Target) { + commit, err := repoCtx.GitRepo.GetBranchCommit(release.Target) + if err != nil { + return fmt.Errorf("GetBranchCommit: %v", err) + } + countCache[release.Target], err = commit.CommitsCount() + if err != nil { + return fmt.Errorf("CommitsCount: %v", err) + } + } else { + // Use NumCommits of the newest release on that target + countCache[release.Target] = release.NumCommits + } + } + release.NumCommitsBehind = countCache[release.Target] - release.NumCommits + return nil +} + +// Releases render releases list page +func Releases(ctx *context.Context) { + releasesOrTags(ctx, false) +} + +// TagsList render tags list page +func TagsList(ctx *context.Context) { + releasesOrTags(ctx, true) +} + +func releasesOrTags(ctx *context.Context, isTagList bool) { + ctx.Data["PageIsReleaseList"] = true + ctx.Data["DefaultBranch"] = ctx.Repo.Repository.DefaultBranch + ctx.Data["IsViewBranch"] = false + ctx.Data["IsViewTag"] = true + // Disable the showCreateNewBranch form in the dropdown on this page. + ctx.Data["CanCreateBranch"] = false + ctx.Data["HideBranchesInDropdown"] = true + + if isTagList { + ctx.Data["Title"] = ctx.Tr("repo.release.tags") + ctx.Data["PageIsTagList"] = true + } else { + ctx.Data["Title"] = ctx.Tr("repo.release.releases") + ctx.Data["PageIsTagList"] = false + } + + tags, err := ctx.Repo.GitRepo.GetTags() + if err != nil { + ctx.ServerError("GetTags", err) + return + } + ctx.Data["Tags"] = tags + + writeAccess := ctx.Repo.CanWrite(models.UnitTypeReleases) + ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived + + opts := models.FindReleasesOptions{ + ListOptions: models.ListOptions{ + Page: ctx.QueryInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.QueryInt("limit")), + }, + IncludeDrafts: writeAccess && !isTagList, + IncludeTags: isTagList, + } + + releases, err := models.GetReleasesByRepoID(ctx.Repo.Repository.ID, opts) + if err != nil { + ctx.ServerError("GetReleasesByRepoID", err) + return + } + + count, err := models.GetReleaseCountByRepoID(ctx.Repo.Repository.ID, opts) + if err != nil { + ctx.ServerError("GetReleaseCountByRepoID", err) + return + } + + if err = models.GetReleaseAttachments(releases...); err != nil { + ctx.ServerError("GetReleaseAttachments", err) + return + } + + // Temporary cache commits count of used branches to speed up. + countCache := make(map[string]int64) + cacheUsers := make(map[int64]*models.User) + if ctx.User != nil { + cacheUsers[ctx.User.ID] = ctx.User + } + var ok bool + + for _, r := range releases { + if r.Publisher, ok = cacheUsers[r.PublisherID]; !ok { + r.Publisher, err = models.GetUserByID(r.PublisherID) + if err != nil { + if models.IsErrUserNotExist(err) { + r.Publisher = models.NewGhostUser() + } else { + ctx.ServerError("GetUserByID", err) + return + } + } + cacheUsers[r.PublisherID] = r.Publisher + } + + r.Note, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, r.Note) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + if r.IsDraft { + continue + } + + if err := calReleaseNumCommitsBehind(ctx.Repo, r, countCache); err != nil { + ctx.ServerError("calReleaseNumCommitsBehind", err) + return + } + } + + ctx.Data["Releases"] = releases + ctx.Data["ReleasesNum"] = len(releases) + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplReleases) +} + +// SingleRelease renders a single release's page +func SingleRelease(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.release.releases") + ctx.Data["PageIsReleaseList"] = true + + writeAccess := ctx.Repo.CanWrite(models.UnitTypeReleases) + ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived + + release, err := models.GetRelease(ctx.Repo.Repository.ID, ctx.Params("*")) + if err != nil { + if models.IsErrReleaseNotExist(err) { + ctx.NotFound("GetRelease", err) + return + } + ctx.ServerError("GetReleasesByRepoID", err) + return + } + + err = models.GetReleaseAttachments(release) + if err != nil { + ctx.ServerError("GetReleaseAttachments", err) + return + } + + release.Publisher, err = models.GetUserByID(release.PublisherID) + if err != nil { + if models.IsErrUserNotExist(err) { + release.Publisher = models.NewGhostUser() + } else { + ctx.ServerError("GetUserByID", err) + return + } + } + if !release.IsDraft { + if err := calReleaseNumCommitsBehind(ctx.Repo, release, make(map[string]int64)); err != nil { + ctx.ServerError("calReleaseNumCommitsBehind", err) + return + } + } + release.Note, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeMetas(), + }, release.Note) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.Data["Releases"] = []*models.Release{release} + ctx.HTML(http.StatusOK, tplReleases) +} + +// LatestRelease redirects to the latest release +func LatestRelease(ctx *context.Context) { + release, err := models.GetLatestReleaseByRepoID(ctx.Repo.Repository.ID) + if err != nil { + if models.IsErrReleaseNotExist(err) { + ctx.NotFound("LatestRelease", err) + return + } + ctx.ServerError("GetLatestReleaseByRepoID", err) + return + } + + if err := release.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + ctx.Redirect(release.HTMLURL()) +} + +// NewRelease render creating or edit release page +func NewRelease(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.release.new_release") + ctx.Data["PageIsReleaseList"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch + if tagName := ctx.Query("tag"); len(tagName) > 0 { + rel, err := models.GetRelease(ctx.Repo.Repository.ID, tagName) + if err != nil && !models.IsErrReleaseNotExist(err) { + ctx.ServerError("GetRelease", err) + return + } + + if rel != nil { + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + ctx.Data["tag_name"] = rel.TagName + ctx.Data["tag_target"] = rel.Target + ctx.Data["title"] = rel.Title + ctx.Data["content"] = rel.Note + ctx.Data["attachments"] = rel.Attachments + } + } + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "release") + ctx.HTML(http.StatusOK, tplReleaseNew) +} + +// NewReleasePost response for creating a release +func NewReleasePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewReleaseForm) + ctx.Data["Title"] = ctx.Tr("repo.release.new_release") + ctx.Data["PageIsReleaseList"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["RequireTribute"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplReleaseNew) + return + } + + if !ctx.Repo.GitRepo.IsBranchExist(form.Target) { + ctx.RenderWithErr(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form) + return + } + + var attachmentUUIDs []string + if setting.Attachment.Enabled { + attachmentUUIDs = form.Files + } + + rel, err := models.GetRelease(ctx.Repo.Repository.ID, form.TagName) + if err != nil { + if !models.IsErrReleaseNotExist(err) { + ctx.ServerError("GetRelease", err) + return + } + + msg := "" + if len(form.Title) > 0 && form.AddTagMsg { + msg = form.Title + "\n\n" + form.Content + } + + if len(form.TagOnly) > 0 { + if err = releaseservice.CreateNewTag(ctx.User, ctx.Repo.Repository, form.Target, form.TagName, msg); err != nil { + if models.IsErrTagAlreadyExists(err) { + e := err.(models.ErrTagAlreadyExists) + ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + ctx.ServerError("releaseservice.CreateNewTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.TagName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + form.TagName) + return + } + + rel = &models.Release{ + RepoID: ctx.Repo.Repository.ID, + PublisherID: ctx.User.ID, + Title: form.Title, + TagName: form.TagName, + Target: form.Target, + Note: form.Content, + IsDraft: len(form.Draft) > 0, + IsPrerelease: form.Prerelease, + IsTag: false, + } + + if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, attachmentUUIDs, msg); err != nil { + ctx.Data["Err_TagName"] = true + switch { + case models.IsErrReleaseAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form) + case models.IsErrInvalidTagName(err): + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form) + default: + ctx.ServerError("CreateRelease", err) + } + return + } + } else { + if !rel.IsTag { + ctx.Data["Err_TagName"] = true + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form) + return + } + + rel.Title = form.Title + rel.Note = form.Content + rel.Target = form.Target + rel.IsDraft = len(form.Draft) > 0 + rel.IsPrerelease = form.Prerelease + rel.PublisherID = ctx.User.ID + rel.IsTag = false + + if err = releaseservice.UpdateRelease(ctx.User, ctx.Repo.GitRepo, rel, attachmentUUIDs, nil, nil); err != nil { + ctx.Data["Err_TagName"] = true + ctx.ServerError("UpdateRelease", err) + return + } + } + log.Trace("Release created: %s/%s:%s", ctx.User.LowerName, ctx.Repo.Repository.Name, form.TagName) + + ctx.Redirect(ctx.Repo.RepoLink + "/releases") +} + +// EditRelease render release edit page +func EditRelease(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") + ctx.Data["PageIsReleaseList"] = true + ctx.Data["PageIsEditRelease"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["RequireTribute"] = true + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "release") + + tagName := ctx.Params("*") + rel, err := models.GetRelease(ctx.Repo.Repository.ID, tagName) + if err != nil { + if models.IsErrReleaseNotExist(err) { + ctx.NotFound("GetRelease", err) + } else { + ctx.ServerError("GetRelease", err) + } + return + } + ctx.Data["ID"] = rel.ID + ctx.Data["tag_name"] = rel.TagName + ctx.Data["tag_target"] = rel.Target + ctx.Data["title"] = rel.Title + ctx.Data["content"] = rel.Note + ctx.Data["prerelease"] = rel.IsPrerelease + ctx.Data["IsDraft"] = rel.IsDraft + + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Data["attachments"] = rel.Attachments + + ctx.HTML(http.StatusOK, tplReleaseNew) +} + +// EditReleasePost response for edit release +func EditReleasePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditReleaseForm) + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") + ctx.Data["PageIsReleaseList"] = true + ctx.Data["PageIsEditRelease"] = true + ctx.Data["RequireSimpleMDE"] = true + ctx.Data["RequireTribute"] = true + + tagName := ctx.Params("*") + rel, err := models.GetRelease(ctx.Repo.Repository.ID, tagName) + if err != nil { + if models.IsErrReleaseNotExist(err) { + ctx.NotFound("GetRelease", err) + } else { + ctx.ServerError("GetRelease", err) + } + return + } + if rel.IsTag { + ctx.NotFound("GetRelease", err) + return + } + ctx.Data["tag_name"] = rel.TagName + ctx.Data["tag_target"] = rel.Target + ctx.Data["title"] = rel.Title + ctx.Data["content"] = rel.Note + ctx.Data["prerelease"] = rel.IsPrerelease + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplReleaseNew) + return + } + + const delPrefix = "attachment-del-" + const editPrefix = "attachment-edit-" + var addAttachmentUUIDs, delAttachmentUUIDs []string + var editAttachments = make(map[string]string) // uuid -> new name + if setting.Attachment.Enabled { + addAttachmentUUIDs = form.Files + for k, v := range ctx.Req.Form { + if strings.HasPrefix(k, delPrefix) && v[0] == "true" { + delAttachmentUUIDs = append(delAttachmentUUIDs, k[len(delPrefix):]) + } else if strings.HasPrefix(k, editPrefix) { + editAttachments[k[len(editPrefix):]] = v[0] + } + } + } + + rel.Title = form.Title + rel.Note = form.Content + rel.IsDraft = len(form.Draft) > 0 + rel.IsPrerelease = form.Prerelease + if err = releaseservice.UpdateRelease(ctx.User, ctx.Repo.GitRepo, + rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil { + ctx.ServerError("UpdateRelease", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/releases") +} + +// DeleteRelease delete a release +func DeleteRelease(ctx *context.Context) { + deleteReleaseOrTag(ctx, false) +} + +// DeleteTag delete a tag +func DeleteTag(ctx *context.Context) { + deleteReleaseOrTag(ctx, true) +} + +func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { + if err := releaseservice.DeleteReleaseByID(ctx.QueryInt64("id"), ctx.User, isDelTag); err != nil { + ctx.Flash.Error("DeleteReleaseByID: " + err.Error()) + } else { + if isDelTag { + ctx.Flash.Success(ctx.Tr("repo.release.deletion_tag_success")) + } else { + ctx.Flash.Success(ctx.Tr("repo.release.deletion_success")) + } + } + + if isDelTag { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/tags", + }) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/releases", + }) +} diff --git a/routers/web/repo/release_test.go b/routers/web/repo/release_test.go new file mode 100644 index 0000000000..004a6ef540 --- /dev/null +++ b/routers/web/repo/release_test.go @@ -0,0 +1,64 @@ +// 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 repo + +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" +) + +func TestNewReleasePost(t *testing.T) { + for _, testCase := range []struct { + RepoID int64 + UserID int64 + TagName string + Form forms.NewReleaseForm + }{ + { + RepoID: 1, + UserID: 2, + TagName: "v1.1", // pre-existing tag + Form: forms.NewReleaseForm{ + TagName: "newtag", + Target: "master", + Title: "title", + Content: "content", + }, + }, + { + RepoID: 1, + UserID: 2, + TagName: "newtag", + Form: forms.NewReleaseForm{ + TagName: "newtag", + Target: "master", + Title: "title", + Content: "content", + }, + }, + } { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/releases/new") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + test.LoadGitRepo(t, ctx) + web.SetForm(ctx, &testCase.Form) + NewReleasePost(ctx) + models.AssertExistsAndLoadBean(t, &models.Release{ + RepoID: 1, + PublisherID: 2, + TagName: testCase.Form.TagName, + Target: testCase.Form.Target, + Title: testCase.Form.Title, + Note: testCase.Form.Content, + }, models.Cond("is_draft=?", len(testCase.Form.Draft) > 0)) + ctx.Repo.GitRepo.Close() + } +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go new file mode 100644 index 0000000000..f149e92a8b --- /dev/null +++ b/routers/web/repo/repo.go @@ -0,0 +1,388 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 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 repo + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "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/web" + archiver_service "code.gitea.io/gitea/services/archiver" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplCreate base.TplName = "repo/create" + tplAlertDetails base.TplName = "base/alert_details" +) + +// MustBeNotEmpty render when a repo is a empty git dir +func MustBeNotEmpty(ctx *context.Context) { + if ctx.Repo.Repository.IsEmpty { + ctx.NotFound("MustBeNotEmpty", nil) + } +} + +// MustBeEditable check that repo can be edited +func MustBeEditable(ctx *context.Context) { + if !ctx.Repo.Repository.CanEnableEditor() || ctx.Repo.IsViewCommit { + ctx.NotFound("", nil) + return + } +} + +// MustBeAbleToUpload check that repo can be uploaded to +func MustBeAbleToUpload(ctx *context.Context) { + if !setting.Repository.Upload.Enabled { + ctx.NotFound("", nil) + } +} + +func checkContextUser(ctx *context.Context, uid int64) *models.User { + orgs, err := models.GetOrgsCanCreateRepoByUserID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return nil + } + + if !ctx.User.IsAdmin { + orgsAvailable := []*models.User{} + for i := 0; i < len(orgs); i++ { + if orgs[i].CanCreateRepo() { + orgsAvailable = append(orgsAvailable, orgs[i]) + } + } + ctx.Data["Orgs"] = orgsAvailable + } else { + ctx.Data["Orgs"] = orgs + } + + // Not equal means current user is an organization. + if uid == ctx.User.ID || uid == 0 { + return ctx.User + } + + org, err := models.GetUserByID(uid) + if models.IsErrUserNotExist(err) { + return ctx.User + } + + if err != nil { + ctx.ServerError("GetUserByID", fmt.Errorf("[%d]: %v", uid, err)) + return nil + } + + // Check ownership of organization. + if !org.IsOrganization() { + ctx.Error(http.StatusForbidden) + return nil + } + if !ctx.User.IsAdmin { + canCreate, err := org.CanCreateOrgRepo(ctx.User.ID) + if err != nil { + ctx.ServerError("CanCreateOrgRepo", err) + return nil + } else if !canCreate { + ctx.Error(http.StatusForbidden) + return nil + } + } else { + ctx.Data["Orgs"] = orgs + } + return org +} + +func getRepoPrivate(ctx *context.Context) bool { + switch strings.ToLower(setting.Repository.DefaultPrivate) { + case setting.RepoCreatingLastUserVisibility: + return ctx.User.LastRepoVisibility + case setting.RepoCreatingPrivate: + return true + case setting.RepoCreatingPublic: + return false + default: + return ctx.User.LastRepoVisibility + } +} + +// Create render creating repository page +func Create(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_repo") + + // Give default value for template to render. + ctx.Data["Gitignores"] = models.Gitignores + ctx.Data["LabelTemplates"] = models.LabelTemplates + ctx.Data["Licenses"] = models.Licenses + ctx.Data["Readmes"] = models.Readmes + ctx.Data["readme"] = "Default" + ctx.Data["private"] = getRepoPrivate(ctx) + ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate + ctx.Data["default_branch"] = setting.Repository.DefaultBranch + + ctxUser := checkContextUser(ctx, ctx.QueryInt64("org")) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + ctx.Data["repo_template_name"] = ctx.Tr("repo.template_select") + templateID := ctx.QueryInt64("template_id") + if templateID > 0 { + templateRepo, err := models.GetRepositoryByID(templateID) + if err == nil && templateRepo.CheckUnitUser(ctxUser, models.UnitTypeCode) { + ctx.Data["repo_template"] = templateID + ctx.Data["repo_template_name"] = templateRepo.Name + } + } + + ctx.Data["CanCreateRepo"] = ctx.User.CanCreateRepo() + ctx.Data["MaxCreationLimit"] = ctx.User.MaxCreationLimit() + + ctx.HTML(http.StatusOK, tplCreate) +} + +func handleCreateError(ctx *context.Context, owner *models.User, err error, name string, tpl base.TplName, form interface{}) { + switch { + case models.IsErrReachLimitOfRepo(err): + ctx.RenderWithErr(ctx.Tr("repo.form.reach_limit_of_creation", owner.MaxCreationLimit()), tpl, form) + case models.IsErrRepoAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) + case models.IsErrRepoFilesAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tpl, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tpl, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tpl, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tpl, form) + } + case models.IsErrNameReserved(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form) + case models.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tpl, form) + default: + ctx.ServerError(name, err) + } +} + +// CreatePost response for creating repository +func CreatePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateRepoForm) + ctx.Data["Title"] = ctx.Tr("new_repo") + + ctx.Data["Gitignores"] = models.Gitignores + ctx.Data["LabelTemplates"] = models.LabelTemplates + ctx.Data["Licenses"] = models.Licenses + ctx.Data["Readmes"] = models.Readmes + + ctx.Data["CanCreateRepo"] = ctx.User.CanCreateRepo() + ctx.Data["MaxCreationLimit"] = ctx.User.MaxCreationLimit() + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplCreate) + return + } + + var repo *models.Repository + var err error + if form.RepoTemplate > 0 { + opts := models.GenerateRepoOptions{ + Name: form.RepoName, + Description: form.Description, + Private: form.Private, + GitContent: form.GitContent, + Topics: form.Topics, + GitHooks: form.GitHooks, + Webhooks: form.Webhooks, + Avatar: form.Avatar, + IssueLabels: form.Labels, + } + + if !opts.IsValid() { + ctx.RenderWithErr(ctx.Tr("repo.template.one_item"), tplCreate, form) + return + } + + templateRepo := getRepository(ctx, form.RepoTemplate) + if ctx.Written() { + return + } + + if !templateRepo.IsTemplate { + ctx.RenderWithErr(ctx.Tr("repo.template.invalid"), tplCreate, form) + return + } + + repo, err = repo_service.GenerateRepository(ctx.User, ctxUser, templateRepo, opts) + if err == nil { + log.Trace("Repository generated [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(ctxUser.HomeLink() + "/" + repo.Name) + return + } + } else { + repo, err = repo_service.CreateRepository(ctx.User, ctxUser, models.CreateRepoOptions{ + Name: form.RepoName, + Description: form.Description, + Gitignores: form.Gitignores, + IssueLabels: form.IssueLabels, + License: form.License, + Readme: form.Readme, + IsPrivate: form.Private || setting.Repository.ForcePrivate, + DefaultBranch: form.DefaultBranch, + AutoInit: form.AutoInit, + IsTemplate: form.Template, + TrustModel: models.ToTrustModel(form.TrustModel), + }) + if err == nil { + log.Trace("Repository created [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(ctxUser.HomeLink() + "/" + repo.Name) + return + } + } + + handleCreateError(ctx, ctxUser, err, "CreatePost", tplCreate, &form) +} + +// Action response for actions to a repository +func Action(ctx *context.Context) { + var err error + switch ctx.Params(":action") { + case "watch": + err = models.WatchRepo(ctx.User.ID, ctx.Repo.Repository.ID, true) + case "unwatch": + err = models.WatchRepo(ctx.User.ID, ctx.Repo.Repository.ID, false) + case "star": + err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, true) + case "unstar": + err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, false) + case "accept_transfer": + err = acceptOrRejectRepoTransfer(ctx, true) + case "reject_transfer": + err = acceptOrRejectRepoTransfer(ctx, false) + case "desc": // FIXME: this is not used + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + + ctx.Repo.Repository.Description = ctx.Query("desc") + ctx.Repo.Repository.Website = ctx.Query("site") + err = models.UpdateRepository(ctx.Repo.Repository, false) + } + + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + + ctx.RedirectToFirst(ctx.Query("redirect_to"), ctx.Repo.RepoLink) +} + +func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) + if err != nil { + return err + } + + if err := repoTransfer.LoadAttributes(); err != nil { + return err + } + + if !repoTransfer.CanUserAcceptTransfer(ctx.User) { + return errors.New("user does not have enough permissions") + } + + if accept { + if err := repo_service.TransferOwnership(repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { + return err + } + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) + } else { + if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { + return err + } + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) + } + + ctx.Redirect(ctx.Repo.Repository.HTMLURL()) + return nil +} + +// RedirectDownload return a file based on the following infos: +func RedirectDownload(ctx *context.Context) { + var ( + vTag = ctx.Params("vTag") + fileName = ctx.Params("fileName") + ) + tagNames := []string{vTag} + curRepo := ctx.Repo.Repository + releases, err := models.GetReleasesByRepoIDAndNames(models.DefaultDBContext(), curRepo.ID, tagNames) + if err != nil { + if models.IsErrAttachmentNotExist(err) { + ctx.Error(http.StatusNotFound) + return + } + ctx.ServerError("RedirectDownload", err) + return + } + if len(releases) == 1 { + release := releases[0] + att, err := models.GetAttachmentByReleaseIDFileName(release.ID, fileName) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + if att != nil { + ctx.Redirect(att.DownloadURL()) + return + } + } + ctx.Error(http.StatusNotFound) +} + +// InitiateDownload will enqueue an archival request, as needed. It may submit +// a request that's already in-progress, but the archiver service will just +// kind of drop it on the floor if this is the case. +func InitiateDownload(ctx *context.Context) { + uri := ctx.Params("*") + aReq := archiver_service.DeriveRequestFrom(ctx, uri) + + if aReq == nil { + ctx.Error(http.StatusNotFound) + return + } + + complete := aReq.IsComplete() + if !complete { + aReq = archiver_service.ArchiveRepository(aReq) + complete, _ = aReq.TimedWaitForCompletion(ctx, 2*time.Second) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "complete": complete, + }) +} diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go new file mode 100644 index 0000000000..d9604bade0 --- /dev/null +++ b/routers/web/repo/search.go @@ -0,0 +1,55 @@ +// 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 repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" +) + +const tplSearch base.TplName = "repo/search" + +// Search render repository search page +func Search(ctx *context.Context) { + if !setting.Indexer.RepoIndexerEnabled { + ctx.Redirect(ctx.Repo.RepoLink, 302) + return + } + language := strings.TrimSpace(ctx.Query("l")) + keyword := strings.TrimSpace(ctx.Query("q")) + page := ctx.QueryInt("page") + if page <= 0 { + page = 1 + } + queryType := strings.TrimSpace(ctx.Query("t")) + isMatch := queryType == "match" + + total, searchResults, searchResultLanguages, err := code_indexer.PerformSearch([]int64{ctx.Repo.Repository.ID}, + language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch) + if err != nil { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["queryType"] = queryType + ctx.Data["SourcePath"] = ctx.Repo.Repository.HTMLURL() + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + ctx.Data["RequireHighlightJS"] = true + ctx.Data["PageIsViewCode"] = true + + pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "l", "Language") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplSearch) +} diff --git a/routers/web/repo/setting.go b/routers/web/repo/setting.go new file mode 100644 index 0000000000..21a82491fe --- /dev/null +++ b/routers/web/repo/setting.go @@ -0,0 +1,1053 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 repo + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/migrations" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + mirror_service "code.gitea.io/gitea/services/mirror" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplSettingsOptions base.TplName = "repo/settings/options" + tplCollaboration base.TplName = "repo/settings/collaboration" + tplBranches base.TplName = "repo/settings/branches" + tplGithooks base.TplName = "repo/settings/githooks" + tplGithookEdit base.TplName = "repo/settings/githook_edit" + tplDeployKeys base.TplName = "repo/settings/deploy_keys" + tplProtectedBranch base.TplName = "repo/settings/protected_branch" +) + +// Settings show a repository's settings page +func Settings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate + + signing, _ := models.SigningKey(ctx.Repo.Repository.RepoPath()) + ctx.Data["SigningKeyAvailable"] = len(signing) > 0 + ctx.Data["SigningSettings"] = setting.Repository.Signing + + ctx.HTML(http.StatusOK, tplSettingsOptions) +} + +// SettingsPost response for changes of a repository +func SettingsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsOptions"] = true + + repo := ctx.Repo.Repository + + switch ctx.Query("action") { + case "update": + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsOptions) + return + } + + newRepoName := form.RepoName + // Check if repository name has been changed. + if repo.LowerName != strings.ToLower(newRepoName) { + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + if err := repo_service.ChangeRepositoryName(ctx.User, repo, newRepoName); err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case models.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form) + case models.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tplSettingsOptions, &form) + case models.IsErrRepoFilesAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplSettingsOptions, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplSettingsOptions, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplSettingsOptions, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplSettingsOptions, form) + } + case models.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + default: + ctx.ServerError("ChangeRepositoryName", err) + } + return + } + + log.Trace("Repository name changed: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newRepoName) + } + // In case it's just a case change. + repo.Name = newRepoName + repo.LowerName = strings.ToLower(newRepoName) + repo.Description = form.Description + repo.Website = form.Website + repo.IsTemplate = form.Template + + // Visibility of forked repository is forced sync with base repository. + if repo.IsFork { + form.Private = repo.BaseRepo.IsPrivate || repo.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate + } + + visibilityChanged := repo.IsPrivate != form.Private + // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public + if visibilityChanged && setting.Repository.ForcePrivate && !form.Private && !ctx.User.IsAdmin { + ctx.ServerError("Force Private enabled", errors.New("cannot change private repository to public")) + return + } + + repo.IsPrivate = form.Private + if err := models.UpdateRepository(repo, visibilityChanged); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "mirror": + if !repo.IsMirror { + ctx.NotFound("", nil) + return + } + + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + interval, err := time.ParseDuration(form.Interval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.Data["Err_Interval"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) + } else { + ctx.Repo.Mirror.EnablePrune = form.EnablePrune + ctx.Repo.Mirror.Interval = interval + if interval != 0 { + ctx.Repo.Mirror.NextUpdateUnix = timeutil.TimeStampNow().AddDuration(interval) + } else { + ctx.Repo.Mirror.NextUpdateUnix = 0 + } + if err := models.UpdateMirror(ctx.Repo.Mirror); err != nil { + ctx.Data["Err_Interval"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) + return + } + } + + oldUsername := mirror_service.Username(ctx.Repo.Mirror) + oldPassword := mirror_service.Password(ctx.Repo.Mirror) + if form.MirrorPassword == "" && form.MirrorUsername == oldUsername { + form.MirrorPassword = oldPassword + } + + address, err := forms.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.User) + } + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + + if err := mirror_service.UpdateAddress(ctx.Repo.Mirror, address); err != nil { + ctx.ServerError("UpdateAddress", err) + return + } + + form.LFS = form.LFS && setting.LFS.StartServer + + if len(form.LFSEndpoint) > 0 { + ep := lfs.DetermineEndpoint("", form.LFSEndpoint) + if ep == nil { + ctx.Data["Err_LFSEndpoint"] = true + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_lfs_endpoint"), tplSettingsOptions, &form) + return + } + err = migrations.IsMigrateURLAllowed(ep.String(), ctx.User) + if err != nil { + ctx.Data["Err_LFSEndpoint"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + } + + ctx.Repo.Mirror.LFS = form.LFS + ctx.Repo.Mirror.LFSEndpoint = form.LFSEndpoint + if err := models.UpdateMirror(ctx.Repo.Mirror); err != nil { + ctx.ServerError("UpdateMirror", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "mirror-sync": + if !repo.IsMirror { + ctx.NotFound("", nil) + return + } + + mirror_service.StartToMirror(repo.ID) + + ctx.Flash.Info(ctx.Tr("repo.settings.mirror_sync_in_progress")) + ctx.Redirect(repo.Link() + "/settings") + + case "advanced": + var repoChanged bool + var units []models.RepoUnit + var deleteUnitTypes []models.UnitType + + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + if repo.CloseIssuesViaCommitInAnyBranch != form.EnableCloseIssuesViaCommitInAnyBranch { + repo.CloseIssuesViaCommitInAnyBranch = form.EnableCloseIssuesViaCommitInAnyBranch + repoChanged = true + } + + if form.EnableWiki && form.EnableExternalWiki && !models.UnitTypeExternalWiki.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalWikiURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_wiki_url_error")) + ctx.Redirect(repo.Link() + "/settings") + return + } + + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeExternalWiki, + Config: &models.ExternalWikiConfig{ + ExternalWikiURL: form.ExternalWikiURL, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeWiki) + } else if form.EnableWiki && !form.EnableExternalWiki && !models.UnitTypeWiki.UnitGlobalDisabled() { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeWiki, + Config: new(models.UnitConfig), + }) + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeExternalWiki) + } else { + if !models.UnitTypeExternalWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeExternalWiki) + } + if !models.UnitTypeWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeWiki) + } + } + + if form.EnableIssues && form.EnableExternalTracker && !models.UnitTypeExternalTracker.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalTrackerURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error")) + ctx.Redirect(repo.Link() + "/settings") + return + } + if len(form.TrackerURLFormat) != 0 && !validation.IsValidExternalTrackerURLFormat(form.TrackerURLFormat) { + ctx.Flash.Error(ctx.Tr("repo.settings.tracker_url_format_error")) + ctx.Redirect(repo.Link() + "/settings") + return + } + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeExternalTracker, + Config: &models.ExternalTrackerConfig{ + ExternalTrackerURL: form.ExternalTrackerURL, + ExternalTrackerFormat: form.TrackerURLFormat, + ExternalTrackerStyle: form.TrackerIssueStyle, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeIssues) + } else if form.EnableIssues && !form.EnableExternalTracker && !models.UnitTypeIssues.UnitGlobalDisabled() { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeIssues, + Config: &models.IssuesConfig{ + EnableTimetracker: form.EnableTimetracker, + AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, + EnableDependencies: form.EnableIssueDependencies, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeExternalTracker) + } else { + if !models.UnitTypeExternalTracker.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeExternalTracker) + } + if !models.UnitTypeIssues.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeIssues) + } + } + + if form.EnableProjects && !models.UnitTypeProjects.UnitGlobalDisabled() { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypeProjects, + }) + } else if !models.UnitTypeProjects.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypeProjects) + } + + if form.EnablePulls && !models.UnitTypePullRequests.UnitGlobalDisabled() { + units = append(units, models.RepoUnit{ + RepoID: repo.ID, + Type: models.UnitTypePullRequests, + Config: &models.PullRequestsConfig{ + IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace, + AllowMerge: form.PullsAllowMerge, + AllowRebase: form.PullsAllowRebase, + AllowRebaseMerge: form.PullsAllowRebaseMerge, + AllowSquash: form.PullsAllowSquash, + AllowManualMerge: form.PullsAllowManualMerge, + AutodetectManualMerge: form.EnableAutodetectManualMerge, + DefaultMergeStyle: models.MergeStyle(form.PullsDefaultMergeStyle), + }, + }) + } else if !models.UnitTypePullRequests.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, models.UnitTypePullRequests) + } + + if err := models.UpdateRepositoryUnits(repo, units, deleteUnitTypes); err != nil { + ctx.ServerError("UpdateRepositoryUnits", err) + return + } + if repoChanged { + if err := models.UpdateRepository(repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + } + log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "signing": + changed := false + + trustModel := models.ToTrustModel(form.TrustModel) + if trustModel != repo.TrustModel { + repo.TrustModel = trustModel + changed = true + } + + if changed { + if err := models.UpdateRepository(repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + } + log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "admin": + if !ctx.User.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + + if repo.IsFsckEnabled != form.EnableHealthCheck { + repo.IsFsckEnabled = form.EnableHealthCheck + } + + if err := models.UpdateRepository(repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + + log.Trace("Repository admin settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "convert": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + if !repo.IsMirror { + ctx.Error(http.StatusNotFound) + return + } + repo.IsMirror = false + + if _, err := repository.CleanUpMigrateInfo(repo); err != nil { + ctx.ServerError("CleanUpMigrateInfo", err) + return + } else if err = models.DeleteMirrorByRepoID(ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("DeleteMirrorByRepoID", err) + return + } + log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed")) + ctx.Redirect(repo.Link()) + + case "convert_fork": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if err := repo.GetOwner(); err != nil { + ctx.ServerError("Convert Fork", err) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + if !repo.IsFork { + ctx.Error(http.StatusNotFound) + return + } + + if !ctx.Repo.Owner.CanCreateRepo() { + ctx.Flash.Error(ctx.Tr("repo.form.reach_limit_of_creation", ctx.User.MaxCreationLimit())) + ctx.Redirect(repo.Link() + "/settings") + return + } + + repo.IsFork = false + repo.ForkID = 0 + if err := models.UpdateRepository(repo, false); err != nil { + log.Error("Unable to update repository %-v whilst converting from fork", repo) + ctx.ServerError("Convert Fork", err) + return + } + + log.Trace("Repository converted from fork to regular: %s", repo.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.convert_fork_succeed")) + ctx.Redirect(repo.Link()) + + case "transfer": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + newOwner, err := models.GetUserByName(ctx.Query("new_owner_name")) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + return + } + ctx.ServerError("IsUserExist", err) + return + } + + if newOwner.Type == models.UserTypeOrganization { + if !ctx.User.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !newOwner.HasMemberWithUserID(ctx.User.ID) { + // The user shouldn't know about this organization + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + return + } + } + + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + + if err := repo_service.StartRepositoryTransfer(ctx.User, newOwner, repo, nil); err != nil { + if models.IsErrRepoAlreadyExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) + } else if models.IsErrRepoTransferInProgress(err) { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) + } else { + ctx.ServerError("TransferOwnership", err) + } + + return + } + + log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) + ctx.Redirect(ctx.Repo.Owner.HomeLink() + "/" + repo.Name + "/settings") + + case "cancel_transfer": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) + if err != nil { + if models.IsErrNoPendingTransfer(err) { + ctx.Flash.Error("repo.settings.transfer_abort_invalid") + ctx.Redirect(ctx.User.HomeLink() + "/" + repo.Name + "/settings") + } else { + ctx.ServerError("GetPendingRepositoryTransfer", err) + } + + return + } + + if err := repoTransfer.LoadAttributes(); err != nil { + ctx.ServerError("LoadRecipient", err) + return + } + + if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { + ctx.ServerError("CancelRepositoryTransfer", err) + return + } + + log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name)) + ctx.Redirect(ctx.Repo.Owner.HomeLink() + "/" + repo.Name + "/settings") + + case "delete": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + // Close the gitrepository before doing this. + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } + + if err := repo_service.DeleteRepository(ctx.User, ctx.Repo.Repository); err != nil { + ctx.ServerError("DeleteRepository", err) + return + } + log.Trace("Repository deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) + ctx.Redirect(ctx.Repo.Owner.DashboardLink()) + + case "delete-wiki": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + err := repo.DeleteWiki() + if err != nil { + log.Error("Delete Wiki: %v", err.Error()) + } + log.Trace("Repository wiki deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "archive": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusForbidden) + return + } + + if repo.IsMirror { + ctx.Flash.Error(ctx.Tr("repo.settings.archive.error_ismirror")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + + if err := repo.SetArchiveRepoState(true); err != nil { + log.Error("Tried to archive a repo: %s", err) + ctx.Flash.Error(ctx.Tr("repo.settings.archive.error")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) + + log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + case "unarchive": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusForbidden) + return + } + + if err := repo.SetArchiveRepoState(false); err != nil { + log.Error("Tried to unarchive a repo: %s", err) + ctx.Flash.Error(ctx.Tr("repo.settings.unarchive.error")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) + + log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + default: + ctx.NotFound("", nil) + } +} + +func handleSettingRemoteAddrError(ctx *context.Context, err error, form *forms.RepoSettingForm) { + if models.IsErrInvalidCloneAddr(err) { + addrErr := err.(*models.ErrInvalidCloneAddr) + switch { + case addrErr.IsProtocolInvalid: + ctx.RenderWithErr(ctx.Tr("repo.mirror_address_protocol_invalid"), tplSettingsOptions, form) + case addrErr.IsURLError: + ctx.RenderWithErr(ctx.Tr("form.url_error"), tplSettingsOptions, form) + case addrErr.IsPermissionDenied: + if addrErr.LocalPath { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied"), tplSettingsOptions, form) + } else if len(addrErr.PrivateNet) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_blocked"), tplSettingsOptions, form) + } else { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_private_ip"), tplSettingsOptions, form) + } + case addrErr.IsInvalidPath: + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_local_path"), tplSettingsOptions, form) + default: + ctx.ServerError("Unknown error", err) + } + } + ctx.RenderWithErr(ctx.Tr("repo.mirror_address_url_invalid"), tplSettingsOptions, form) +} + +// Collaboration render a repository's collaboration page +func Collaboration(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsCollaboration"] = true + + users, err := ctx.Repo.Repository.GetCollaborators(models.ListOptions{}) + if err != nil { + ctx.ServerError("GetCollaborators", err) + return + } + ctx.Data["Collaborators"] = users + + teams, err := ctx.Repo.Repository.GetRepoTeams() + if err != nil { + ctx.ServerError("GetRepoTeams", err) + return + } + ctx.Data["Teams"] = teams + ctx.Data["Repo"] = ctx.Repo.Repository + ctx.Data["OrgID"] = ctx.Repo.Repository.OwnerID + ctx.Data["OrgName"] = ctx.Repo.Repository.OwnerName + ctx.Data["Org"] = ctx.Repo.Repository.Owner + ctx.Data["Units"] = models.Units + + ctx.HTML(http.StatusOK, tplCollaboration) +} + +// CollaborationPost response for actions for a collaboration of a repository +func CollaborationPost(ctx *context.Context) { + name := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.Query("collaborator"))) + if len(name) == 0 || ctx.Repo.Owner.LowerName == name { + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) + return + } + + u, err := models.GetUserByName(name) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if !u.IsActive { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_inactive_user")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) + return + } + + // Organization is not allowed to be added as a collaborator. + if u.IsOrganization() { + ctx.Flash.Error(ctx.Tr("repo.settings.org_not_allowed_to_be_collaborator")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) + return + } + + if got, err := ctx.Repo.Repository.IsCollaborator(u.ID); err == nil && got { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_duplicate")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + if err = ctx.Repo.Repository.AddCollaborator(u); err != nil { + ctx.ServerError("AddCollaborator", err) + return + } + + if setting.Service.EnableNotifyMail { + mailer.SendCollaboratorMail(u, ctx.User, ctx.Repo.Repository) + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_collaborator_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) +} + +// ChangeCollaborationAccessMode response for changing access of a collaboration +func ChangeCollaborationAccessMode(ctx *context.Context) { + if err := ctx.Repo.Repository.ChangeCollaborationAccessMode( + ctx.QueryInt64("uid"), + models.AccessMode(ctx.QueryInt("mode"))); err != nil { + log.Error("ChangeCollaborationAccessMode: %v", err) + } +} + +// DeleteCollaboration delete a collaboration for a repository +func DeleteCollaboration(ctx *context.Context) { + if err := ctx.Repo.Repository.DeleteCollaboration(ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteCollaboration: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/settings/collaboration", + }) +} + +// AddTeamPost response for adding a team to a repository +func AddTeamPost(ctx *context.Context) { + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + name := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.Query("team"))) + if len(name) == 0 { + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + team, err := ctx.Repo.Owner.GetTeam(name) + if err != nil { + if models.IsErrTeamNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.team_not_exist")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + } else { + ctx.ServerError("GetTeam", err) + } + return + } + + if team.OrgID != ctx.Repo.Repository.OwnerID { + ctx.Flash.Error(ctx.Tr("repo.settings.team_not_in_organization")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + if models.HasTeamRepo(ctx.Repo.Repository.OwnerID, team.ID, ctx.Repo.Repository.ID) { + ctx.Flash.Error(ctx.Tr("repo.settings.add_team_duplicate")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + if err = team.AddRepository(ctx.Repo.Repository); err != nil { + ctx.ServerError("team.AddRepository", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_team_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") +} + +// DeleteTeam response for deleting a team from a repository +func DeleteTeam(ctx *context.Context) { + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + team, err := models.GetTeamByID(ctx.QueryInt64("id")) + if err != nil { + ctx.ServerError("GetTeamByID", err) + return + } + + if err = team.RemoveRepository(ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("team.RemoveRepositorys", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.remove_team_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/settings/collaboration", + }) +} + +// parseOwnerAndRepo get repos by owner +func parseOwnerAndRepo(ctx *context.Context) (*models.User, *models.Repository) { + owner, err := models.GetUserByName(ctx.Params(":username")) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", err) + } else { + ctx.ServerError("GetUserByName", err) + } + return nil, nil + } + + repo, err := models.GetRepositoryByName(owner.ID, ctx.Params(":reponame")) + if err != nil { + if models.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByName", err) + } else { + ctx.ServerError("GetRepositoryByName", err) + } + return nil, nil + } + + return owner, repo +} + +// GitHooks hooks of a repository +func GitHooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.githooks") + ctx.Data["PageIsSettingsGitHooks"] = true + + hooks, err := ctx.Repo.GitRepo.Hooks() + if err != nil { + ctx.ServerError("Hooks", err) + return + } + ctx.Data["Hooks"] = hooks + + ctx.HTML(http.StatusOK, tplGithooks) +} + +// GitHooksEdit render for editing a hook of repository page +func GitHooksEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.githooks") + ctx.Data["PageIsSettingsGitHooks"] = true + + name := ctx.Params(":name") + hook, err := ctx.Repo.GitRepo.GetHook(name) + if err != nil { + if err == git.ErrNotValidHook { + ctx.NotFound("GetHook", err) + } else { + ctx.ServerError("GetHook", err) + } + return + } + ctx.Data["Hook"] = hook + ctx.HTML(http.StatusOK, tplGithookEdit) +} + +// GitHooksEditPost response for editing a git hook of a repository +func GitHooksEditPost(ctx *context.Context) { + name := ctx.Params(":name") + hook, err := ctx.Repo.GitRepo.GetHook(name) + if err != nil { + if err == git.ErrNotValidHook { + ctx.NotFound("GetHook", err) + } else { + ctx.ServerError("GetHook", err) + } + return + } + hook.Content = ctx.Query("content") + if err = hook.Update(); err != nil { + ctx.ServerError("hook.Update", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/hooks/git") +} + +// DeployKeys render the deploy keys list of a repository page +func DeployKeys(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + + keys, err := models.ListDeployKeys(ctx.Repo.Repository.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListDeployKeys", err) + return + } + ctx.Data["Deploykeys"] = keys + + ctx.HTML(http.StatusOK, tplDeployKeys) +} + +// DeployKeysPost response for adding a deploy key of a repository +func DeployKeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddKeyForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + ctx.Data["PageIsSettingsKeys"] = true + + keys, err := models.ListDeployKeys(ctx.Repo.Repository.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListDeployKeys", err) + return + } + ctx.Data["Deploykeys"] = keys + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplDeployKeys) + return + } + + content, err := models.CheckPublicKeyString(form.Content) + if err != nil { + if models.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if models.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else { + ctx.Data["HasError"] = true + ctx.Data["Err_Content"] = true + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") + return + } + + key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable) + if err != nil { + ctx.Data["HasError"] = true + switch { + case models.IsErrDeployKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_been_used"), tplDeployKeys, &form) + case models.IsErrKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplDeployKeys, &form) + case models.IsErrKeyNameAlreadyUsed(err): + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), tplDeployKeys, &form) + case models.IsErrDeployKeyNameAlreadyUsed(err): + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), tplDeployKeys, &form) + default: + ctx.ServerError("AddDeployKey", err) + } + return + } + + log.Trace("Deploy key added: %d", ctx.Repo.Repository.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") +} + +// DeleteDeployKey response for deleting a deploy key +func DeleteDeployKey(ctx *context.Context) { + if err := models.DeleteDeployKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteDeployKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/settings/keys", + }) +} + +// UpdateAvatarSetting update repo's avatar +func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error { + ctxRepo := ctx.Repo.Repository + + if form.Avatar == nil { + // No avatar is uploaded and we not removing it here. + // No random avatar generated here. + // Just exit, no action. + if ctxRepo.CustomAvatarRelativePath() == "" { + log.Trace("No avatar was uploaded for repo: %d. Default icon will appear instead.", ctxRepo.ID) + } + return nil + } + + r, err := form.Avatar.Open() + if err != nil { + return fmt.Errorf("Avatar.Open: %v", err) + } + defer r.Close() + + if form.Avatar.Size > setting.Avatar.MaxFileSize { + return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big")) + } + + data, err := ioutil.ReadAll(r) + if err != nil { + return fmt.Errorf("ioutil.ReadAll: %v", err) + } + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) + } + if err = ctxRepo.UploadAvatar(data); err != nil { + return fmt.Errorf("UploadAvatar: %v", err) + } + return nil +} + +// SettingsAvatar save new POSTed repository avatar +func SettingsAvatar(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AvatarForm) + form.Source = forms.AvatarLocal + if err := UpdateAvatarSetting(ctx, *form); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.update_avatar_success")) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} + +// SettingsDeleteAvatar delete repository avatar +func SettingsDeleteAvatar(ctx *context.Context) { + if err := ctx.Repo.Repository.DeleteAvatar(); err != nil { + ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err)) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} diff --git a/routers/web/repo/setting_protected_branch.go b/routers/web/repo/setting_protected_branch.go new file mode 100644 index 0000000000..fba2c095cf --- /dev/null +++ b/routers/web/repo/setting_protected_branch.go @@ -0,0 +1,286 @@ +// 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 repo + +import ( + "fmt" + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + pull_service "code.gitea.io/gitea/services/pull" +) + +// ProtectedBranch render the page to protect the repository +func ProtectedBranch(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsBranches"] = true + + protectedBranches, err := ctx.Repo.Repository.GetProtectedBranches() + if err != nil { + ctx.ServerError("GetProtectedBranches", err) + return + } + ctx.Data["ProtectedBranches"] = protectedBranches + + branches := ctx.Data["Branches"].([]string) + leftBranches := make([]string, 0, len(branches)-len(protectedBranches)) + for _, b := range branches { + var protected bool + for _, pb := range protectedBranches { + if b == pb.BranchName { + protected = true + break + } + } + if !protected { + leftBranches = append(leftBranches, b) + } + } + + ctx.Data["LeftBranches"] = leftBranches + + ctx.HTML(http.StatusOK, tplBranches) +} + +// ProtectedBranchPost response for protect for a branch of a repository +func ProtectedBranchPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsBranches"] = true + + repo := ctx.Repo.Repository + + switch ctx.Query("action") { + case "default_branch": + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplBranches) + return + } + + branch := ctx.Query("branch") + if !ctx.Repo.GitRepo.IsBranchExist(branch) { + ctx.Status(404) + return + } else if repo.DefaultBranch != branch { + repo.DefaultBranch = branch + if err := ctx.Repo.GitRepo.SetDefaultBranch(branch); err != nil { + if !git.IsErrUnsupportedVersion(err) { + ctx.ServerError("SetDefaultBranch", err) + return + } + } + if err := repo.UpdateDefaultBranch(); err != nil { + ctx.ServerError("SetDefaultBranch", err) + return + } + } + + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) + default: + ctx.NotFound("", nil) + } +} + +// SettingsProtectedBranch renders the protected branch setting page +func SettingsProtectedBranch(c *context.Context) { + branch := c.Params("*") + if !c.Repo.GitRepo.IsBranchExist(branch) { + c.NotFound("IsBranchExist", nil) + return + } + + c.Data["Title"] = c.Tr("repo.settings.protected_branch") + " - " + branch + c.Data["PageIsSettingsBranches"] = true + + protectBranch, err := models.GetProtectedBranchBy(c.Repo.Repository.ID, branch) + if err != nil { + if !git.IsErrBranchNotExist(err) { + c.ServerError("GetProtectBranchOfRepoByName", err) + return + } + } + + if protectBranch == nil { + // No options found, create defaults. + protectBranch = &models.ProtectedBranch{ + BranchName: branch, + } + } + + users, err := c.Repo.Repository.GetReaders() + if err != nil { + c.ServerError("Repo.Repository.GetReaders", err) + return + } + c.Data["Users"] = users + c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(protectBranch.WhitelistUserIDs), ",") + c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(protectBranch.MergeWhitelistUserIDs), ",") + c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(protectBranch.ApprovalsWhitelistUserIDs), ",") + contexts, _ := models.FindRepoRecentCommitStatusContexts(c.Repo.Repository.ID, 7*24*time.Hour) // Find last week status check contexts + for _, ctx := range protectBranch.StatusCheckContexts { + var found bool + for i := range contexts { + if contexts[i] == ctx { + found = true + break + } + } + if !found { + contexts = append(contexts, ctx) + } + } + + c.Data["branch_status_check_contexts"] = contexts + c.Data["is_context_required"] = func(context string) bool { + for _, c := range protectBranch.StatusCheckContexts { + if c == context { + return true + } + } + return false + } + + if c.Repo.Owner.IsOrganization() { + teams, err := c.Repo.Owner.TeamsWithAccessToRepo(c.Repo.Repository.ID, models.AccessModeRead) + if err != nil { + c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) + return + } + c.Data["Teams"] = teams + c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(protectBranch.WhitelistTeamIDs), ",") + c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(protectBranch.MergeWhitelistTeamIDs), ",") + c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(protectBranch.ApprovalsWhitelistTeamIDs), ",") + } + + c.Data["Branch"] = protectBranch + c.HTML(http.StatusOK, tplProtectedBranch) +} + +// SettingsProtectedBranchPost updates the protected branch settings +func SettingsProtectedBranchPost(ctx *context.Context) { + f := web.GetForm(ctx).(*forms.ProtectBranchForm) + branch := ctx.Params("*") + if !ctx.Repo.GitRepo.IsBranchExist(branch) { + ctx.NotFound("IsBranchExist", nil) + return + } + + protectBranch, err := models.GetProtectedBranchBy(ctx.Repo.Repository.ID, branch) + if err != nil { + if !git.IsErrBranchNotExist(err) { + ctx.ServerError("GetProtectBranchOfRepoByName", err) + return + } + } + + if f.Protected { + if protectBranch == nil { + // No options found, create defaults. + protectBranch = &models.ProtectedBranch{ + RepoID: ctx.Repo.Repository.ID, + BranchName: branch, + } + } + if f.RequiredApprovals < 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/%s", ctx.Repo.RepoLink, branch)) + } + + var whitelistUsers, whitelistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 + switch f.EnablePush { + case "all": + protectBranch.CanPush = true + protectBranch.EnableWhitelist = false + protectBranch.WhitelistDeployKeys = false + case "whitelist": + protectBranch.CanPush = true + protectBranch.EnableWhitelist = true + protectBranch.WhitelistDeployKeys = f.WhitelistDeployKeys + if strings.TrimSpace(f.WhitelistUsers) != "" { + whitelistUsers, _ = base.StringsToInt64s(strings.Split(f.WhitelistUsers, ",")) + } + if strings.TrimSpace(f.WhitelistTeams) != "" { + whitelistTeams, _ = base.StringsToInt64s(strings.Split(f.WhitelistTeams, ",")) + } + default: + protectBranch.CanPush = false + protectBranch.EnableWhitelist = false + protectBranch.WhitelistDeployKeys = false + } + + protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist + if f.EnableMergeWhitelist { + if strings.TrimSpace(f.MergeWhitelistUsers) != "" { + mergeWhitelistUsers, _ = base.StringsToInt64s(strings.Split(f.MergeWhitelistUsers, ",")) + } + if strings.TrimSpace(f.MergeWhitelistTeams) != "" { + mergeWhitelistTeams, _ = base.StringsToInt64s(strings.Split(f.MergeWhitelistTeams, ",")) + } + } + + protectBranch.EnableStatusCheck = f.EnableStatusCheck + if f.EnableStatusCheck { + protectBranch.StatusCheckContexts = f.StatusCheckContexts + } else { + protectBranch.StatusCheckContexts = nil + } + + protectBranch.RequiredApprovals = f.RequiredApprovals + protectBranch.EnableApprovalsWhitelist = f.EnableApprovalsWhitelist + if f.EnableApprovalsWhitelist { + if strings.TrimSpace(f.ApprovalsWhitelistUsers) != "" { + approvalsWhitelistUsers, _ = base.StringsToInt64s(strings.Split(f.ApprovalsWhitelistUsers, ",")) + } + if strings.TrimSpace(f.ApprovalsWhitelistTeams) != "" { + approvalsWhitelistTeams, _ = base.StringsToInt64s(strings.Split(f.ApprovalsWhitelistTeams, ",")) + } + } + protectBranch.BlockOnRejectedReviews = f.BlockOnRejectedReviews + protectBranch.BlockOnOfficialReviewRequests = f.BlockOnOfficialReviewRequests + protectBranch.DismissStaleApprovals = f.DismissStaleApprovals + protectBranch.RequireSignedCommits = f.RequireSignedCommits + protectBranch.ProtectedFilePatterns = f.ProtectedFilePatterns + protectBranch.BlockOnOutdatedBranch = f.BlockOnOutdatedBranch + + err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, models.WhitelistOptions{ + UserIDs: whitelistUsers, + TeamIDs: whitelistTeams, + MergeUserIDs: mergeWhitelistUsers, + MergeTeamIDs: mergeWhitelistTeams, + ApprovalsUserIDs: approvalsWhitelistUsers, + ApprovalsTeamIDs: approvalsWhitelistTeams, + }) + if err != nil { + ctx.ServerError("UpdateProtectBranch", err) + return + } + if err = pull_service.CheckPrsForBaseBranch(ctx.Repo.Repository, protectBranch.BranchName); err != nil { + ctx.ServerError("CheckPrsForBaseBranch", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", branch)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/%s", ctx.Repo.RepoLink, branch)) + } else { + if protectBranch != nil { + if err := ctx.Repo.Repository.DeleteProtectedBranch(protectBranch.ID); err != nil { + ctx.ServerError("DeleteProtectedBranch", err) + return + } + } + ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", branch)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + } +} diff --git a/routers/web/repo/settings_test.go b/routers/web/repo/settings_test.go new file mode 100644 index 0000000000..5190f12d5d --- /dev/null +++ b/routers/web/repo/settings_test.go @@ -0,0 +1,413 @@ +// 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 repo + +import ( + "io/ioutil" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" +) + +func createSSHAuthorizedKeysTmpPath(t *testing.T) func() { + tmpDir, err := ioutil.TempDir("", "tmp-ssh") + if err != nil { + assert.Fail(t, "Unable to create temporary directory: %v", err) + return nil + } + + oldPath := setting.SSH.RootPath + setting.SSH.RootPath = tmpDir + + return func() { + setting.SSH.RootPath = oldPath + util.RemoveAll(tmpDir) + } +} + +func TestAddReadOnlyDeployKey(t *testing.T) { + if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { + defer deferable() + } else { + return + } + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/settings/keys") + + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 2) + + addKeyForm := forms.AddKeyForm{ + Title: "read-only", + Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + } + web.SetForm(ctx, &addKeyForm) + DeployKeysPost(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + + models.AssertExistsAndLoadBean(t, &models.DeployKey{ + Name: addKeyForm.Title, + Content: addKeyForm.Content, + Mode: models.AccessModeRead, + }) +} + +func TestAddReadWriteOnlyDeployKey(t *testing.T) { + if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { + defer deferable() + } else { + return + } + + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/settings/keys") + + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 2) + + addKeyForm := forms.AddKeyForm{ + Title: "read-write", + Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + IsWritable: true, + } + web.SetForm(ctx, &addKeyForm) + DeployKeysPost(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + + models.AssertExistsAndLoadBean(t, &models.DeployKey{ + Name: addKeyForm.Title, + Content: addKeyForm.Content, + Mode: models.AccessModeWrite, + }) +} + +func TestCollaborationPost(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/issues/labels") + test.LoadUser(t, ctx, 2) + test.LoadUser(t, ctx, 4) + test.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user4") + + u := &models.User{ + LowerName: "user2", + Type: models.UserTypeIndividual, + } + + re := &models.Repository{ + ID: 2, + Owner: u, + } + + repo := &context.Repository{ + Owner: u, + Repository: re, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + + exists, err := re.IsCollaborator(4) + assert.NoError(t, err) + assert.True(t, exists) +} + +func TestCollaborationPost_InactiveUser(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/issues/labels") + test.LoadUser(t, ctx, 2) + test.LoadUser(t, ctx, 9) + test.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user9") + + repo := &context.Repository{ + Owner: &models.User{ + LowerName: "user2", + }, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/issues/labels") + test.LoadUser(t, ctx, 2) + test.LoadUser(t, ctx, 4) + test.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user4") + + u := &models.User{ + LowerName: "user2", + Type: models.UserTypeIndividual, + } + + re := &models.Repository{ + ID: 2, + Owner: u, + } + + repo := &context.Repository{ + Owner: u, + Repository: re, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + + exists, err := re.IsCollaborator(4) + assert.NoError(t, err) + assert.True(t, exists) + + // Try adding the same collaborator again + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestCollaborationPost_NonExistentUser(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/issues/labels") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user34") + + repo := &context.Repository{ + Owner: &models.User{ + LowerName: "user2", + }, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &models.User{ + LowerName: "org26", + Type: models.UserTypeOrganization, + } + + team := &models.Team{ + ID: 11, + OrgID: 26, + } + + re := &models.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &models.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + assert.True(t, team.HasRepository(re.ID)) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assert.Empty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_NotAllowed(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &models.User{ + LowerName: "org26", + Type: models.UserTypeOrganization, + } + + team := &models.Team{ + ID: 11, + OrgID: 26, + } + + re := &models.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &models.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: false, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + assert.False(t, team.HasRepository(re.ID)) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) + +} + +func TestAddTeamPost_AddTeamTwice(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &models.User{ + LowerName: "org26", + Type: models.UserTypeOrganization, + } + + team := &models.Team{ + ID: 11, + OrgID: 26, + } + + re := &models.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &models.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + AddTeamPost(ctx) + assert.True(t, team.HasRepository(re.ID)) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_NonExistentTeam(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team-non-existent") + + org := &models.User{ + LowerName: "org26", + Type: models.UserTypeOrganization, + } + + re := &models.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &models.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestDeleteTeam(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "org3/team1/repo3") + + ctx.Req.Form.Set("id", "2") + + org := &models.User{ + LowerName: "org3", + Type: models.UserTypeOrganization, + } + + team := &models.Team{ + ID: 2, + OrgID: 3, + } + + re := &models.Repository{ + ID: 3, + Owner: org, + OwnerID: 3, + } + + repo := &context.Repository{ + Owner: &models.User{ + ID: 3, + LowerName: "org3", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + DeleteTeam(ctx) + + assert.False(t, team.HasRepository(re.ID)) +} diff --git a/routers/web/repo/topic.go b/routers/web/repo/topic.go new file mode 100644 index 0000000000..1d99b65094 --- /dev/null +++ b/routers/web/repo/topic.go @@ -0,0 +1,61 @@ +// 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 repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" +) + +// TopicsPost response for creating repository +func TopicsPost(ctx *context.Context) { + if ctx.User == nil { + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "message": "Only owners could change the topics.", + }) + return + } + + var topics = make([]string, 0) + var topicsStr = strings.TrimSpace(ctx.Query("topics")) + if len(topicsStr) > 0 { + topics = strings.Split(topicsStr, ",") + } + + validTopics, invalidTopics := models.SanitizeAndValidateTopics(topics) + + if len(validTopics) > 25 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{ + "invalidTopics": nil, + "message": ctx.Tr("repo.topic.count_prompt"), + }) + return + } + + if len(invalidTopics) > 0 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{ + "invalidTopics": invalidTopics, + "message": ctx.Tr("repo.topic.format_prompt"), + }) + return + } + + err := models.SaveTopics(ctx.Repo.Repository.ID, validTopics...) + if err != nil { + log.Error("SaveTopics failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "Save topics failed.", + }) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "status": "ok", + }) +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go new file mode 100644 index 0000000000..cd5b0f43ed --- /dev/null +++ b/routers/web/repo/view.go @@ -0,0 +1,808 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// 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 repo + +import ( + "bytes" + "encoding/base64" + "fmt" + gotemplate "html/template" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" +) + +const ( + tplRepoEMPTY base.TplName = "repo/empty" + tplRepoHome base.TplName = "repo/home" + tplWatchers base.TplName = "repo/watchers" + tplForks base.TplName = "repo/forks" + tplMigrating base.TplName = "repo/migrate/migrating" +) + +type namedBlob struct { + name string + isSymlink bool + blob *git.Blob +} + +func linesBytesCount(s []byte) int { + nl := []byte{'\n'} + n := bytes.Count(s, nl) + if len(s) > 0 && !bytes.HasSuffix(s, nl) { + n++ + } + return n +} + +// FIXME: There has to be a more efficient way of doing this +func getReadmeFileFromPath(commit *git.Commit, treePath string) (*namedBlob, error) { + tree, err := commit.SubTree(treePath) + if err != nil { + return nil, err + } + + entries, err := tree.ListEntries() + if err != nil { + return nil, err + } + + var readmeFiles [4]*namedBlob + var exts = []string{".md", ".txt", ""} // sorted by priority + for _, entry := range entries { + if entry.IsDir() { + continue + } + for i, ext := range exts { + if markup.IsReadmeFile(entry.Name(), ext) { + if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].name, entry.Blob().Name()) { + name := entry.Name() + isSymlink := entry.IsLink() + target := entry + if isSymlink { + target, err = entry.FollowLinks() + if err != nil && !git.IsErrBadLink(err) { + return nil, err + } + } + if target != nil && (target.IsExecutable() || target.IsRegular()) { + readmeFiles[i] = &namedBlob{ + name, + isSymlink, + target.Blob(), + } + } + } + } + } + + if markup.IsReadmeFile(entry.Name()) { + if readmeFiles[3] == nil || base.NaturalSortLess(readmeFiles[3].name, entry.Blob().Name()) { + name := entry.Name() + isSymlink := entry.IsLink() + if isSymlink { + entry, err = entry.FollowLinks() + if err != nil && !git.IsErrBadLink(err) { + return nil, err + } + } + if entry != nil && (entry.IsExecutable() || entry.IsRegular()) { + readmeFiles[3] = &namedBlob{ + name, + isSymlink, + entry.Blob(), + } + } + } + } + } + var readmeFile *namedBlob + for _, f := range readmeFiles { + if f != nil { + readmeFile = f + break + } + } + return readmeFile, nil +} + +func renderDirectory(ctx *context.Context, treeLink string) { + tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + if err != nil { + ctx.NotFoundOrServerError("Repo.Commit.SubTree", git.IsErrNotExist, err) + return + } + + entries, err := tree.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return + } + entries.CustomSort(base.NaturalSortLess) + + var c *git.LastCommitCache + if setting.CacheService.LastCommit.Enabled && ctx.Repo.CommitsCount >= setting.CacheService.LastCommit.CommitsCount { + c = git.NewLastCommitCache(ctx.Repo.Repository.FullName(), ctx.Repo.GitRepo, setting.LastCommitCacheTTLSeconds, cache.GetCache()) + } + + var latestCommit *git.Commit + ctx.Data["Files"], latestCommit, err = entries.GetCommitsInfo(ctx, ctx.Repo.Commit, ctx.Repo.TreePath, c) + if err != nil { + ctx.ServerError("GetCommitsInfo", err) + return + } + + // 3 for the extensions in exts[] in order + // the last one is for a readme that doesn't + // strictly match an extension + var readmeFiles [4]*namedBlob + var docsEntries [3]*git.TreeEntry + var exts = []string{".md", ".txt", ""} // sorted by priority + for _, entry := range entries { + if entry.IsDir() { + lowerName := strings.ToLower(entry.Name()) + switch lowerName { + case "docs": + if entry.Name() == "docs" || docsEntries[0] == nil { + docsEntries[0] = entry + } + case ".gitea": + if entry.Name() == ".gitea" || docsEntries[1] == nil { + docsEntries[1] = entry + } + case ".github": + if entry.Name() == ".github" || docsEntries[2] == nil { + docsEntries[2] = entry + } + } + continue + } + + for i, ext := range exts { + if markup.IsReadmeFile(entry.Name(), ext) { + log.Debug("%s", entry.Name()) + name := entry.Name() + isSymlink := entry.IsLink() + target := entry + if isSymlink { + target, err = entry.FollowLinks() + if err != nil && !git.IsErrBadLink(err) { + ctx.ServerError("FollowLinks", err) + return + } + } + log.Debug("%t", target == nil) + if target != nil && (target.IsExecutable() || target.IsRegular()) { + readmeFiles[i] = &namedBlob{ + name, + isSymlink, + target.Blob(), + } + } + } + } + + if markup.IsReadmeFile(entry.Name()) { + name := entry.Name() + isSymlink := entry.IsLink() + if isSymlink { + entry, err = entry.FollowLinks() + if err != nil && !git.IsErrBadLink(err) { + ctx.ServerError("FollowLinks", err) + return + } + } + if entry != nil && (entry.IsExecutable() || entry.IsRegular()) { + readmeFiles[3] = &namedBlob{ + name, + isSymlink, + entry.Blob(), + } + } + } + } + + var readmeFile *namedBlob + readmeTreelink := treeLink + for _, f := range readmeFiles { + if f != nil { + readmeFile = f + break + } + } + + if ctx.Repo.TreePath == "" && readmeFile == nil { + for _, entry := range docsEntries { + if entry == nil { + continue + } + readmeFile, err = getReadmeFileFromPath(ctx.Repo.Commit, entry.GetSubJumpablePathName()) + if err != nil { + ctx.ServerError("getReadmeFileFromPath", err) + return + } + if readmeFile != nil { + readmeFile.name = entry.Name() + "/" + readmeFile.name + readmeTreelink = treeLink + "/" + entry.GetSubJumpablePathName() + break + } + } + } + + if readmeFile != nil { + ctx.Data["RawFileLink"] = "" + ctx.Data["ReadmeInList"] = true + ctx.Data["ReadmeExist"] = true + ctx.Data["FileIsSymlink"] = readmeFile.isSymlink + + dataRc, err := readmeFile.blob.DataAsync() + if err != nil { + ctx.ServerError("Data", err) + return + } + defer dataRc.Close() + + buf := make([]byte, 1024) + n, _ := dataRc.Read(buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + isTextFile := st.IsText() + + ctx.Data["FileIsText"] = isTextFile + ctx.Data["FileName"] = readmeFile.name + fileSize := int64(0) + isLFSFile := false + ctx.Data["IsLFSFile"] = false + + // FIXME: what happens when README file is an image? + if isTextFile && setting.LFS.StartServer { + pointer, _ := lfs.ReadPointerFromBuffer(buf) + if pointer.IsValid() { + meta, err := ctx.Repo.Repository.GetLFSMetaObjectByOid(pointer.Oid) + if err != nil && err != models.ErrLFSObjectNotExist { + ctx.ServerError("GetLFSMetaObject", err) + return + } + if meta != nil { + ctx.Data["IsLFSFile"] = true + isLFSFile = true + + // OK read the lfs object + var err error + dataRc, err = lfs.ReadMetaObject(pointer) + if err != nil { + ctx.ServerError("ReadMetaObject", err) + return + } + defer dataRc.Close() + + buf = make([]byte, 1024) + n, err = dataRc.Read(buf) + if err != nil { + ctx.ServerError("Data", err) + return + } + buf = buf[:n] + + st = typesniffer.DetectContentType(buf) + isTextFile = st.IsText() + ctx.Data["IsTextFile"] = isTextFile + + fileSize = meta.Size + ctx.Data["FileSize"] = meta.Size + filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.name)) + ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s.git/info/lfs/objects/%s/%s", setting.AppURL, ctx.Repo.Repository.FullName(), meta.Oid, filenameBase64) + } + } + } + + if !isLFSFile { + fileSize = readmeFile.blob.Size() + } + + if isTextFile { + if fileSize >= setting.UI.MaxDisplayFileSize { + // Pretend that this is a normal text file to display 'This file is too large to be shown' + ctx.Data["IsFileTooLarge"] = true + ctx.Data["IsTextFile"] = true + ctx.Data["FileSize"] = fileSize + } else { + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc)) + + if markupType := markup.Type(readmeFile.name); markupType != "" { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = string(markupType) + var result strings.Builder + err := markup.Render(&markup.RenderContext{ + Filename: readmeFile.name, + URLPrefix: readmeTreelink, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(), + }, rd, &result) + if err != nil { + log.Error("Render failed: %v then fallback", err) + bs, _ := ioutil.ReadAll(rd) + ctx.Data["FileContent"] = strings.ReplaceAll( + gotemplate.HTMLEscapeString(string(bs)), "\n", `<br>`, + ) + } else { + ctx.Data["FileContent"] = result.String() + } + } else { + ctx.Data["IsRenderedHTML"] = true + ctx.Data["FileContent"] = strings.ReplaceAll( + gotemplate.HTMLEscapeString(string(buf)), "\n", `<br>`, + ) + } + } + } + } + + // Show latest commit info of repository in table header, + // or of directory if not in root directory. + ctx.Data["LatestCommit"] = latestCommit + verification := models.ParseCommitWithSignature(latestCommit) + + if err := models.CalculateTrustStatus(verification, ctx.Repo.Repository, nil); err != nil { + ctx.ServerError("CalculateTrustStatus", err) + return + } + ctx.Data["LatestCommitVerification"] = verification + + ctx.Data["LatestCommitUser"] = models.ValidateCommitWithEmail(latestCommit) + + statuses, err := models.GetLatestCommitStatus(ctx.Repo.Repository.ID, ctx.Repo.Commit.ID.String(), models.ListOptions{}) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + + ctx.Data["LatestCommitStatus"] = models.CalcCommitStatus(statuses) + ctx.Data["LatestCommitStatuses"] = statuses + + // Check permission to add or upload new file. + if ctx.Repo.CanWrite(models.UnitTypeCode) && ctx.Repo.IsViewBranch { + ctx.Data["CanAddFile"] = !ctx.Repo.Repository.IsArchived + ctx.Data["CanUploadFile"] = setting.Repository.Upload.Enabled && !ctx.Repo.Repository.IsArchived + } + + ctx.Data["SSHDomain"] = setting.SSH.Domain +} + +func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink string) { + ctx.Data["IsViewFile"] = true + blob := entry.Blob() + dataRc, err := blob.DataAsync() + if err != nil { + ctx.ServerError("DataAsync", err) + return + } + defer dataRc.Close() + + ctx.Data["Title"] = ctx.Data["Title"].(string) + " - " + ctx.Repo.TreePath + " at " + ctx.Repo.BranchName + + fileSize := blob.Size() + ctx.Data["FileIsSymlink"] = entry.IsLink() + ctx.Data["FileName"] = blob.Name() + ctx.Data["RawFileLink"] = rawLink + "/" + ctx.Repo.TreePath + + buf := make([]byte, 1024) + n, _ := dataRc.Read(buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + isTextFile := st.IsText() + + isLFSFile := false + isDisplayingSource := ctx.Query("display") == "source" + isDisplayingRendered := !isDisplayingSource + + //Check for LFS meta file + if isTextFile && setting.LFS.StartServer { + pointer, _ := lfs.ReadPointerFromBuffer(buf) + if pointer.IsValid() { + meta, err := ctx.Repo.Repository.GetLFSMetaObjectByOid(pointer.Oid) + if err != nil && err != models.ErrLFSObjectNotExist { + ctx.ServerError("GetLFSMetaObject", err) + return + } + if meta != nil { + isLFSFile = true + + // OK read the lfs object + var err error + dataRc, err = lfs.ReadMetaObject(pointer) + if err != nil { + ctx.ServerError("ReadMetaObject", err) + return + } + defer dataRc.Close() + + buf = make([]byte, 1024) + n, err = dataRc.Read(buf) + // Error EOF don't mean there is an error, it just means we read to + // the end + if err != nil && err != io.EOF { + ctx.ServerError("Data", err) + return + } + buf = buf[:n] + + st = typesniffer.DetectContentType(buf) + isTextFile = st.IsText() + + fileSize = meta.Size + ctx.Data["RawFileLink"] = fmt.Sprintf("%s/media/%s/%s", ctx.Repo.RepoLink, ctx.Repo.BranchNameSubURL(), ctx.Repo.TreePath) + } + } + } + + isRepresentableAsText := st.IsRepresentableAsText() + if !isRepresentableAsText { + // If we can't show plain text, always try to render. + isDisplayingSource = false + isDisplayingRendered = true + } + ctx.Data["IsLFSFile"] = isLFSFile + ctx.Data["FileSize"] = fileSize + ctx.Data["IsTextFile"] = isTextFile + ctx.Data["IsRepresentableAsText"] = isRepresentableAsText + ctx.Data["IsDisplayingSource"] = isDisplayingSource + ctx.Data["IsDisplayingRendered"] = isDisplayingRendered + ctx.Data["IsTextSource"] = isTextFile || isDisplayingSource + + // Check LFS Lock + lfsLock, err := ctx.Repo.Repository.GetTreePathLock(ctx.Repo.TreePath) + ctx.Data["LFSLock"] = lfsLock + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + if lfsLock != nil { + ctx.Data["LFSLockOwner"] = lfsLock.Owner.DisplayName() + ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") + } + + // Assume file is not editable first. + if isLFSFile { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") + } else if !isRepresentableAsText { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") + } + + switch { + case isRepresentableAsText: + if st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + ctx.Data["HasSourceRenderedToggle"] = true + } + + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc)) + readmeExist := markup.IsReadmeFile(blob.Name()) + ctx.Data["ReadmeExist"] = readmeExist + if markupType := markup.Type(blob.Name()); markupType != "" { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + var result strings.Builder + err := markup.Render(&markup.RenderContext{ + Filename: blob.Name(), + URLPrefix: path.Dir(treeLink), + Metas: ctx.Repo.Repository.ComposeDocumentMetas(), + }, rd, &result) + if err != nil { + ctx.ServerError("Render", err) + return + } + ctx.Data["FileContent"] = result.String() + } else if readmeExist { + buf, _ := ioutil.ReadAll(rd) + ctx.Data["IsRenderedHTML"] = true + ctx.Data["FileContent"] = strings.ReplaceAll( + gotemplate.HTMLEscapeString(string(buf)), "\n", `<br>`, + ) + } else { + buf, _ := ioutil.ReadAll(rd) + lineNums := linesBytesCount(buf) + ctx.Data["NumLines"] = strconv.Itoa(lineNums) + ctx.Data["NumLinesSet"] = true + ctx.Data["FileContent"] = highlight.File(lineNums, blob.Name(), buf) + } + if !isLFSFile { + if ctx.Repo.CanEnableEditor() { + if lfsLock != nil && lfsLock.OwnerID != ctx.User.ID { + ctx.Data["CanEditFile"] = false + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") + } else { + ctx.Data["CanEditFile"] = true + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") + } + } else if !ctx.Repo.IsViewBranch { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + } else if !ctx.Repo.CanWrite(models.UnitTypeCode) { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") + } + } + + case st.IsPDF(): + ctx.Data["IsPDFFile"] = true + case st.IsVideo(): + ctx.Data["IsVideoFile"] = true + case st.IsAudio(): + ctx.Data["IsAudioFile"] = true + case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): + ctx.Data["IsImageFile"] = true + default: + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + if markupType := markup.Type(blob.Name()); markupType != "" { + rd := io.MultiReader(bytes.NewReader(buf), dataRc) + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + var result strings.Builder + err := markup.Render(&markup.RenderContext{ + Filename: blob.Name(), + URLPrefix: path.Dir(treeLink), + Metas: ctx.Repo.Repository.ComposeDocumentMetas(), + }, rd, &result) + if err != nil { + ctx.ServerError("Render", err) + return + } + ctx.Data["FileContent"] = result.String() + } + } + + if ctx.Repo.CanEnableEditor() { + if lfsLock != nil && lfsLock.OwnerID != ctx.User.ID { + ctx.Data["CanDeleteFile"] = false + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") + } else { + ctx.Data["CanDeleteFile"] = true + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file") + } + } else if !ctx.Repo.IsViewBranch { + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + } else if !ctx.Repo.CanWrite(models.UnitTypeCode) { + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") + } +} + +func safeURL(address string) string { + u, err := url.Parse(address) + if err != nil { + return address + } + u.User = nil + return u.String() +} + +// Home render repository home page +func Home(ctx *context.Context) { + if len(ctx.Repo.Units) > 0 { + if ctx.Repo.Repository.IsBeingCreated() { + task, err := models.GetMigratingTask(ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("models.GetMigratingTask", err) + return + } + cfg, err := task.MigrateConfig() + if err != nil { + ctx.ServerError("task.MigrateConfig", err) + return + } + + ctx.Data["Repo"] = ctx.Repo + ctx.Data["MigrateTask"] = task + ctx.Data["CloneAddr"] = safeURL(cfg.CloneAddr) + ctx.HTML(http.StatusOK, tplMigrating) + return + } + + if ctx.IsSigned { + // Set repo notification-status read if unread + if err := ctx.Repo.Repository.ReadBy(ctx.User.ID); err != nil { + ctx.ServerError("ReadBy", err) + return + } + } + + var firstUnit *models.Unit + for _, repoUnit := range ctx.Repo.Units { + if repoUnit.Type == models.UnitTypeCode { + renderCode(ctx) + return + } + + unit, ok := models.Units[repoUnit.Type] + if ok && (firstUnit == nil || !firstUnit.IsLessThan(unit)) { + firstUnit = &unit + } + } + + if firstUnit != nil { + ctx.Redirect(fmt.Sprintf("%s/%s%s", setting.AppSubURL, ctx.Repo.Repository.FullName(), firstUnit.URI)) + return + } + } + + ctx.NotFound("Home", fmt.Errorf(ctx.Tr("units.error.no_unit_allowed_repo"))) +} + +func renderLanguageStats(ctx *context.Context) { + langs, err := ctx.Repo.Repository.GetTopLanguageStats(5) + if err != nil { + ctx.ServerError("Repo.GetTopLanguageStats", err) + return + } + + ctx.Data["LanguageStats"] = langs +} + +func renderRepoTopics(ctx *context.Context) { + topics, err := models.FindTopics(&models.FindTopicOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("models.FindTopics", err) + return + } + ctx.Data["Topics"] = topics +} + +func renderCode(ctx *context.Context) { + ctx.Data["PageIsViewCode"] = true + + if ctx.Repo.Repository.IsEmpty { + ctx.HTML(http.StatusOK, tplRepoEMPTY) + return + } + + title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name + if len(ctx.Repo.Repository.Description) > 0 { + title += ": " + ctx.Repo.Repository.Description + } + ctx.Data["Title"] = title + + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + + if len(ctx.Repo.TreePath) > 0 { + treeLink += "/" + ctx.Repo.TreePath + } + + // Get Topics of this repo + renderRepoTopics(ctx) + if ctx.Written() { + return + } + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + ctx.NotFoundOrServerError("Repo.Commit.GetTreeEntryByPath", git.IsErrNotExist, err) + return + } + + renderLanguageStats(ctx) + if ctx.Written() { + return + } + + if entry.IsDir() { + renderDirectory(ctx, treeLink) + } else { + renderFile(ctx, entry, treeLink, rawLink) + } + if ctx.Written() { + return + } + + var treeNames []string + paths := make([]string, 0, 5) + if len(ctx.Repo.TreePath) > 0 { + treeNames = strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + + ctx.Data["HasParentPath"] = true + if len(paths)-2 >= 0 { + ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] + } + } + + ctx.Data["Paths"] = paths + ctx.Data["TreeLink"] = treeLink + ctx.Data["TreeNames"] = treeNames + ctx.Data["BranchLink"] = branchLink + ctx.HTML(http.StatusOK, tplRepoHome) +} + +// RenderUserCards render a page show users according the input templaet +func RenderUserCards(ctx *context.Context, total int, getter func(opts models.ListOptions) ([]*models.User, error), tpl base.TplName) { + page := ctx.QueryInt("page") + if page <= 0 { + page = 1 + } + pager := context.NewPagination(total, models.ItemsPerPage, page, 5) + ctx.Data["Page"] = pager + + items, err := getter(models.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: models.ItemsPerPage, + }) + if err != nil { + ctx.ServerError("getter", err) + return + } + ctx.Data["Cards"] = items + + ctx.HTML(http.StatusOK, tpl) +} + +// Watchers render repository's watch users +func Watchers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.watchers") + ctx.Data["CardsTitle"] = ctx.Tr("repo.watchers") + ctx.Data["PageIsWatchers"] = true + + RenderUserCards(ctx, ctx.Repo.Repository.NumWatches, ctx.Repo.Repository.GetWatchers, tplWatchers) +} + +// Stars render repository's starred users +func Stars(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.stargazers") + ctx.Data["CardsTitle"] = ctx.Tr("repo.stargazers") + ctx.Data["PageIsStargazers"] = true + RenderUserCards(ctx, ctx.Repo.Repository.NumStars, ctx.Repo.Repository.GetStargazers, tplWatchers) +} + +// Forks render repository's forked users +func Forks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repos.forks") + + // TODO: need pagination + forks, err := ctx.Repo.Repository.GetForks(models.ListOptions{}) + if err != nil { + ctx.ServerError("GetForks", err) + return + } + + for _, fork := range forks { + if err = fork.GetOwner(); err != nil { + ctx.ServerError("GetOwner", err) + return + } + } + ctx.Data["Forks"] = forks + + ctx.HTML(http.StatusOK, tplForks) +} diff --git a/routers/web/repo/webhook.go b/routers/web/repo/webhook.go new file mode 100644 index 0000000000..fe16d249eb --- /dev/null +++ b/routers/web/repo/webhook.go @@ -0,0 +1,1131 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// 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 repo + +import ( + "errors" + "fmt" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook" + jsoniter "github.com/json-iterator/go" +) + +const ( + tplHooks base.TplName = "repo/settings/webhook/base" + tplHookNew base.TplName = "repo/settings/webhook/new" + tplOrgHookNew base.TplName = "org/settings/hook_new" + tplAdminHookNew base.TplName = "admin/hook_new" +) + +// Webhooks render web hooks list page +func Webhooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.hooks") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = ctx.Repo.RepoLink + "/settings/hooks" + ctx.Data["BaseLinkNew"] = ctx.Repo.RepoLink + "/settings/hooks" + ctx.Data["Description"] = ctx.Tr("repo.settings.hooks_desc", "https://docs.gitea.io/en-us/webhooks/") + + ws, err := models.GetWebhooksByRepoID(ctx.Repo.Repository.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("GetWebhooksByRepoID", err) + return + } + ctx.Data["Webhooks"] = ws + + ctx.HTML(http.StatusOK, tplHooks) +} + +type orgRepoCtx struct { + OrgID int64 + RepoID int64 + IsAdmin bool + IsSystemWebhook bool + Link string + LinkNew string + NewTemplate base.TplName +} + +// getOrgRepoCtx determines whether this is a repo, organization, or admin (both default and system) context. +func getOrgRepoCtx(ctx *context.Context) (*orgRepoCtx, error) { + if len(ctx.Repo.RepoLink) > 0 { + return &orgRepoCtx{ + RepoID: ctx.Repo.Repository.ID, + Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"), + LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"), + NewTemplate: tplHookNew, + }, nil + } + + if len(ctx.Org.OrgLink) > 0 { + return &orgRepoCtx{ + OrgID: ctx.Org.Organization.ID, + Link: path.Join(ctx.Org.OrgLink, "settings/hooks"), + LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"), + NewTemplate: tplOrgHookNew, + }, nil + } + + if ctx.User.IsAdmin { + // Are we looking at default webhooks? + if ctx.Params(":configType") == "default-hooks" { + return &orgRepoCtx{ + IsAdmin: true, + Link: path.Join(setting.AppSubURL, "/admin/hooks"), + LinkNew: path.Join(setting.AppSubURL, "/admin/default-hooks"), + NewTemplate: tplAdminHookNew, + }, nil + } + + // Must be system webhooks instead + return &orgRepoCtx{ + IsAdmin: true, + IsSystemWebhook: true, + Link: path.Join(setting.AppSubURL, "/admin/hooks"), + LinkNew: path.Join(setting.AppSubURL, "/admin/system-hooks"), + NewTemplate: tplAdminHookNew, + }, nil + } + + return nil, errors.New("Unable to set OrgRepo context") +} + +func checkHookType(ctx *context.Context) string { + hookType := strings.ToLower(ctx.Params(":type")) + if !util.IsStringInSlice(hookType, setting.Webhook.Types, true) { + ctx.NotFound("checkHookType", nil) + return "" + } + return hookType +} + +// WebhooksNew render creating webhook page +func WebhooksNew(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if orCtx.IsAdmin && orCtx.IsSystemWebhook { + ctx.Data["PageIsAdminSystemHooks"] = true + ctx.Data["PageIsAdminSystemHooksNew"] = true + } else if orCtx.IsAdmin { + ctx.Data["PageIsAdminDefaultHooks"] = true + ctx.Data["PageIsAdminDefaultHooksNew"] = true + } else { + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + } + + hookType := checkHookType(ctx) + ctx.Data["HookType"] = hookType + if ctx.Written() { + return + } + if hookType == "discord" { + ctx.Data["DiscordHook"] = map[string]interface{}{ + "Username": "Gitea", + "IconURL": setting.AppURL + "img/favicon.png", + } + } + ctx.Data["BaseLink"] = orCtx.LinkNew + + ctx.HTML(http.StatusOK, orCtx.NewTemplate) +} + +// ParseHookEvent convert web form content to models.HookEvent +func ParseHookEvent(form forms.WebhookForm) *models.HookEvent { + return &models.HookEvent{ + PushOnly: form.PushOnly(), + SendEverything: form.SendEverything(), + ChooseEvents: form.ChooseEvents(), + HookEvents: models.HookEvents{ + Create: form.Create, + Delete: form.Delete, + Fork: form.Fork, + Issues: form.Issues, + IssueAssign: form.IssueAssign, + IssueLabel: form.IssueLabel, + IssueMilestone: form.IssueMilestone, + IssueComment: form.IssueComment, + Release: form.Release, + Push: form.Push, + PullRequest: form.PullRequest, + PullRequestAssign: form.PullRequestAssign, + PullRequestLabel: form.PullRequestLabel, + PullRequestMilestone: form.PullRequestMilestone, + PullRequestComment: form.PullRequestComment, + PullRequestReview: form.PullRequestReview, + PullRequestSync: form.PullRequestSync, + Repository: form.Repository, + }, + BranchFilter: form.BranchFilter, + } +} + +// GiteaHooksNewPost response for creating Gitea webhook +func GiteaHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewWebhookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.GITEA + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + ctx.Data["BaseLink"] = orCtx.LinkNew + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + contentType := models.ContentTypeJSON + if models.HookContentType(form.ContentType) == models.ContentTypeForm { + contentType = models.ContentTypeForm + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + HTTPMethod: form.HTTPMethod, + ContentType: contentType, + Secret: form.Secret, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.GITEA, + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// GogsHooksNewPost response for creating webhook +func GogsHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewGogshookForm) + newGogsWebhookPost(ctx, *form, models.GOGS) +} + +// newGogsWebhookPost response for creating gogs hook +func newGogsWebhookPost(ctx *context.Context, form forms.NewGogshookForm, kind models.HookTaskType) { + ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.GOGS + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + ctx.Data["BaseLink"] = orCtx.LinkNew + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + contentType := models.ContentTypeJSON + if models.HookContentType(form.ContentType) == models.ContentTypeForm { + contentType = models.ContentTypeForm + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + ContentType: contentType, + Secret: form.Secret, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: kind, + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// DiscordHooksNewPost response for creating discord hook +func DiscordHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewDiscordHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.DISCORD + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.DiscordMeta{ + Username: form.Username, + IconURL: form.IconURL, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + ContentType: models.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.DISCORD, + Meta: string(meta), + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// DingtalkHooksNewPost response for creating dingtalk hook +func DingtalkHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewDingtalkHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.DINGTALK + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + ContentType: models.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.DINGTALK, + Meta: "", + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// TelegramHooksNewPost response for creating telegram hook +func TelegramHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewTelegramHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.TELEGRAM + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.TelegramMeta{ + BotToken: form.BotToken, + ChatID: form.ChatID, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s", form.BotToken, form.ChatID), + ContentType: models.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.TELEGRAM, + Meta: string(meta), + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// MatrixHooksNewPost response for creating a Matrix hook +func MatrixHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewMatrixHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.MATRIX + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.MatrixMeta{ + HomeserverURL: form.HomeserverURL, + Room: form.RoomID, + AccessToken: form.AccessToken, + MessageType: form.MessageType, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, form.RoomID), + ContentType: models.ContentTypeJSON, + HTTPMethod: "PUT", + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.MATRIX, + Meta: string(meta), + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// MSTeamsHooksNewPost response for creating MS Teams hook +func MSTeamsHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewMSTeamsHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.MSTEAMS + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + ContentType: models.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.MSTEAMS, + Meta: "", + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// SlackHooksNewPost response for creating slack hook +func SlackHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewSlackHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.SLACK + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + if form.HasInvalidChannel() { + ctx.Flash.Error(ctx.Tr("repo.settings.add_webhook.invalid_channel_name")) + ctx.Redirect(orCtx.LinkNew + "/slack/new") + return + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.SlackMeta{ + Channel: strings.TrimSpace(form.Channel), + Username: form.Username, + IconURL: form.IconURL, + Color: form.Color, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + ContentType: models.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.SLACK, + Meta: string(meta), + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// FeishuHooksNewPost response for creating feishu hook +func FeishuHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewFeishuHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + ctx.Data["HookType"] = models.FEISHU + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + ContentType: models.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: models.FEISHU, + Meta: "", + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +func checkWebhook(ctx *context.Context) (*orgRepoCtx, *models.Webhook) { + ctx.Data["RequireHighlightJS"] = true + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return nil, nil + } + ctx.Data["BaseLink"] = orCtx.Link + + var w *models.Webhook + if orCtx.RepoID > 0 { + w, err = models.GetWebhookByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + } else if orCtx.OrgID > 0 { + w, err = models.GetWebhookByOrgID(ctx.Org.Organization.ID, ctx.ParamsInt64(":id")) + } else if orCtx.IsAdmin { + w, err = models.GetSystemOrDefaultWebhook(ctx.ParamsInt64(":id")) + } + if err != nil || w == nil { + if models.IsErrWebhookNotExist(err) { + ctx.NotFound("GetWebhookByID", nil) + } else { + ctx.ServerError("GetWebhookByID", err) + } + return nil, nil + } + + ctx.Data["HookType"] = w.Type + switch w.Type { + case models.SLACK: + ctx.Data["SlackHook"] = webhook.GetSlackHook(w) + case models.DISCORD: + ctx.Data["DiscordHook"] = webhook.GetDiscordHook(w) + case models.TELEGRAM: + ctx.Data["TelegramHook"] = webhook.GetTelegramHook(w) + case models.MATRIX: + ctx.Data["MatrixHook"] = webhook.GetMatrixHook(w) + } + + ctx.Data["History"], err = w.History(1) + if err != nil { + ctx.ServerError("History", err) + } + return orCtx, w +} + +// WebHooksEdit render editing web hook page +func WebHooksEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + ctx.HTML(http.StatusOK, orCtx.NewTemplate) +} + +// WebHooksEditPost response for editing web hook +func WebHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewWebhookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + contentType := models.ContentTypeJSON + if models.HookContentType(form.ContentType) == models.ContentTypeForm { + contentType = models.ContentTypeForm + } + + w.URL = form.PayloadURL + w.ContentType = contentType + w.Secret = form.Secret + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + w.HTTPMethod = form.HTTPMethod + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("WebHooksEditPost", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// GogsHooksEditPost response for editing gogs hook +func GogsHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewGogshookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + contentType := models.ContentTypeJSON + if models.HookContentType(form.ContentType) == models.ContentTypeForm { + contentType = models.ContentTypeForm + } + + w.URL = form.PayloadURL + w.ContentType = contentType + w.Secret = form.Secret + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("GogsHooksEditPost", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// SlackHooksEditPost response for editing slack hook +func SlackHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewSlackHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + if form.HasInvalidChannel() { + ctx.Flash.Error(ctx.Tr("repo.settings.add_webhook.invalid_channel_name")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) + return + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.SlackMeta{ + Channel: strings.TrimSpace(form.Channel), + Username: form.Username, + IconURL: form.IconURL, + Color: form.Color, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w.URL = form.PayloadURL + w.Meta = string(meta) + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// DiscordHooksEditPost response for editing discord hook +func DiscordHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewDiscordHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.DiscordMeta{ + Username: form.Username, + IconURL: form.IconURL, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w.URL = form.PayloadURL + w.Meta = string(meta) + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// DingtalkHooksEditPost response for editing discord hook +func DingtalkHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewDingtalkHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + w.URL = form.PayloadURL + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// TelegramHooksEditPost response for editing discord hook +func TelegramHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewTelegramHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.TelegramMeta{ + BotToken: form.BotToken, + ChatID: form.ChatID, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + w.Meta = string(meta) + w.URL = fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s", form.BotToken, form.ChatID) + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// MatrixHooksEditPost response for editing a Matrix hook +func MatrixHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewMatrixHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + json := jsoniter.ConfigCompatibleWithStandardLibrary + meta, err := json.Marshal(&webhook.MatrixMeta{ + HomeserverURL: form.HomeserverURL, + Room: form.RoomID, + AccessToken: form.AccessToken, + MessageType: form.MessageType, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + w.Meta = string(meta) + w.URL = fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, form.RoomID) + + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// MSTeamsHooksEditPost response for editing MS Teams hook +func MSTeamsHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewMSTeamsHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + w.URL = form.PayloadURL + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// FeishuHooksEditPost response for editing feishu hook +func FeishuHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewFeishuHookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + w.URL = form.PayloadURL + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// TestWebhook test if web hook is work fine +func TestWebhook(ctx *context.Context) { + hookID := ctx.ParamsInt64(":id") + w, err := models.GetWebhookByRepoID(ctx.Repo.Repository.ID, hookID) + if err != nil { + ctx.Flash.Error("GetWebhookByID: " + err.Error()) + ctx.Status(500) + return + } + + // Grab latest commit or fake one if it's empty repository. + commit := ctx.Repo.Commit + if commit == nil { + ghost := models.NewGhostUser() + commit = &git.Commit{ + ID: git.MustIDFromString(git.EmptySHA), + Author: ghost.NewGitSig(), + Committer: ghost.NewGitSig(), + CommitMessage: "This is a fake commit", + } + } + + apiUser := convert.ToUserWithAccessMode(ctx.User, models.AccessModeNone) + p := &api.PushPayload{ + Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch, + Before: commit.ID.String(), + After: commit.ID.String(), + Commits: []*api.PayloadCommit{ + { + ID: commit.ID.String(), + Message: commit.Message(), + URL: ctx.Repo.Repository.HTMLURL() + "/commit/" + commit.ID.String(), + Author: &api.PayloadUser{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + Committer: &api.PayloadUser{ + Name: commit.Committer.Name, + Email: commit.Committer.Email, + }, + }, + }, + Repo: convert.ToRepo(ctx.Repo.Repository, models.AccessModeNone), + Pusher: apiUser, + Sender: apiUser, + } + if err := webhook.PrepareWebhook(w, ctx.Repo.Repository, models.HookEventPush, p); err != nil { + ctx.Flash.Error("PrepareWebhook: " + err.Error()) + ctx.Status(500) + } else { + ctx.Flash.Info(ctx.Tr("repo.settings.webhook.test_delivery_success")) + ctx.Status(200) + } +} + +// DeleteWebhook delete a webhook +func DeleteWebhook(ctx *context.Context) { + if err := models.DeleteWebhookByRepoID(ctx.Repo.Repository.ID, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/settings/hooks", + }) +} diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go new file mode 100644 index 0000000000..cceb8451e5 --- /dev/null +++ b/routers/web/repo/wiki.go @@ -0,0 +1,684 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// 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 repo + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path/filepath" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/forms" + wiki_service "code.gitea.io/gitea/services/wiki" +) + +const ( + tplWikiStart base.TplName = "repo/wiki/start" + tplWikiView base.TplName = "repo/wiki/view" + tplWikiRevision base.TplName = "repo/wiki/revision" + tplWikiNew base.TplName = "repo/wiki/new" + tplWikiPages base.TplName = "repo/wiki/pages" +) + +// MustEnableWiki check if wiki is enabled, if external then redirect +func MustEnableWiki(ctx *context.Context) { + if !ctx.Repo.CanRead(models.UnitTypeWiki) && + !ctx.Repo.CanRead(models.UnitTypeExternalWiki) { + if log.IsTrace() { + log.Trace("Permission Denied: User %-v cannot read %-v or %-v of repo %-v\n"+ + "User in repo has Permissions: %-+v", + ctx.User, + models.UnitTypeWiki, + models.UnitTypeExternalWiki, + ctx.Repo.Repository, + ctx.Repo.Permission) + } + ctx.NotFound("MustEnableWiki", nil) + return + } + + unit, err := ctx.Repo.Repository.GetUnit(models.UnitTypeExternalWiki) + if err == nil { + ctx.Redirect(unit.ExternalWikiConfig().ExternalWikiURL) + return + } +} + +// PageMeta wiki page meta information +type PageMeta struct { + Name string + SubURL string + UpdatedUnix timeutil.TimeStamp +} + +// findEntryForFile finds the tree entry for a target filepath. +func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) { + entry, err := commit.GetTreeEntryByPath(target) + if err != nil && !git.IsErrNotExist(err) { + return nil, err + } + if entry != nil { + return entry, nil + } + + // Then the unescaped, shortest alternative + var unescapedTarget string + if unescapedTarget, err = url.QueryUnescape(target); err != nil { + return nil, err + } + return commit.GetTreeEntryByPath(unescapedTarget) +} + +func findWikiRepoCommit(ctx *context.Context) (*git.Repository, *git.Commit, error) { + wikiRepo, err := git.OpenRepository(ctx.Repo.Repository.WikiPath()) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil, nil, err + } + + commit, err := wikiRepo.GetBranchCommit("master") + if err != nil { + return wikiRepo, nil, err + } + return wikiRepo, commit, nil +} + +// wikiContentsByEntry returns the contents of the wiki page referenced by the +// given tree entry. Writes to ctx if an error occurs. +func wikiContentsByEntry(ctx *context.Context, entry *git.TreeEntry) []byte { + reader, err := entry.Blob().DataAsync() + if err != nil { + ctx.ServerError("Blob.Data", err) + return nil + } + defer reader.Close() + content, err := ioutil.ReadAll(reader) + if err != nil { + ctx.ServerError("ReadAll", err) + return nil + } + return content +} + +// wikiContentsByName returns the contents of a wiki page, along with a boolean +// indicating whether the page exists. Writes to ctx if an error occurs. +func wikiContentsByName(ctx *context.Context, commit *git.Commit, wikiName string) ([]byte, *git.TreeEntry, string, bool) { + pageFilename := wiki_service.NameToFilename(wikiName) + entry, err := findEntryForFile(commit, pageFilename) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findEntryForFile", err) + return nil, nil, "", false + } else if entry == nil { + return nil, nil, "", true + } + return wikiContentsByEntry(ctx, entry), entry, pageFilename, false +} + +func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if !git.IsErrNotExist(err) { + ctx.ServerError("GetBranchCommit", err) + } + return nil, nil + } + + // Get page list. + entries, err := commit.ListEntries() + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("ListEntries", err) + return nil, nil + } + pages := make([]PageMeta, 0, len(entries)) + for _, entry := range entries { + if !entry.IsRegular() { + continue + } + wikiName, err := wiki_service.FilenameToName(entry.Name()) + if err != nil { + if models.IsErrWikiInvalidFileName(err) { + continue + } + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("WikiFilenameToName", err) + return nil, nil + } else if wikiName == "_Sidebar" || wikiName == "_Footer" { + continue + } + pages = append(pages, PageMeta{ + Name: wikiName, + SubURL: wiki_service.NameToSubURL(wikiName), + }) + } + ctx.Data["Pages"] = pages + + // get requested pagename + pageName := wiki_service.NormalizeWikiName(ctx.Params(":page")) + if len(pageName) == 0 { + pageName = "Home" + } + ctx.Data["PageURL"] = wiki_service.NameToSubURL(pageName) + ctx.Data["old_title"] = pageName + ctx.Data["Title"] = pageName + ctx.Data["title"] = pageName + ctx.Data["RequireHighlightJS"] = true + + //lookup filename in wiki - get filecontent, gitTree entry , real filename + data, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName) + if noEntry { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/_pages") + } + if entry == nil || ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + + sidebarContent, _, _, _ := wikiContentsByName(ctx, commit, "_Sidebar") + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + + footerContent, _, _, _ := wikiContentsByName(ctx, commit, "_Footer") + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + + var rctx = &markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(), + IsWiki: true, + } + + var buf strings.Builder + if err := markdown.Render(rctx, bytes.NewReader(data), &buf); err != nil { + ctx.ServerError("Render", err) + return nil, nil + } + ctx.Data["content"] = buf.String() + + buf.Reset() + if err := markdown.Render(rctx, bytes.NewReader(sidebarContent), &buf); err != nil { + ctx.ServerError("Render", err) + return nil, nil + } + ctx.Data["sidebarPresent"] = sidebarContent != nil + ctx.Data["sidebarContent"] = buf.String() + + buf.Reset() + if err := markdown.Render(rctx, bytes.NewReader(footerContent), &buf); err != nil { + ctx.ServerError("Render", err) + return nil, nil + } + ctx.Data["footerPresent"] = footerContent != nil + ctx.Data["footerContent"] = buf.String() + + // get commit count - wiki revisions + commitsCount, _ := wikiRepo.FileCommitsCount("master", pageFilename) + ctx.Data["CommitCount"] = commitsCount + + return wikiRepo, entry +} + +func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + if !git.IsErrNotExist(err) { + ctx.ServerError("GetBranchCommit", err) + } + return nil, nil + } + + // get requested pagename + pageName := wiki_service.NormalizeWikiName(ctx.Params(":page")) + if len(pageName) == 0 { + pageName = "Home" + } + ctx.Data["PageURL"] = wiki_service.NameToSubURL(pageName) + ctx.Data["old_title"] = pageName + ctx.Data["Title"] = pageName + ctx.Data["title"] = pageName + ctx.Data["RequireHighlightJS"] = true + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + + //lookup filename in wiki - get filecontent, gitTree entry , real filename + data, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName) + if noEntry { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/_pages") + } + if entry == nil || ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + + ctx.Data["content"] = string(data) + ctx.Data["sidebarPresent"] = false + ctx.Data["sidebarContent"] = "" + ctx.Data["footerPresent"] = false + ctx.Data["footerContent"] = "" + + // get commit count - wiki revisions + commitsCount, _ := wikiRepo.FileCommitsCount("master", pageFilename) + ctx.Data["CommitCount"] = commitsCount + + // get page + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + + // get Commit Count + commitsHistory, err := wikiRepo.CommitsByFileAndRangeNoFollow("master", pageFilename, page) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("CommitsByFileAndRangeNoFollow", err) + return nil, nil + } + commitsHistory = models.ValidateCommitsWithEmails(commitsHistory) + commitsHistory = models.ParseCommitsWithSignature(commitsHistory, ctx.Repo.Repository) + + ctx.Data["Commits"] = commitsHistory + + pager := context.NewPagination(int(commitsCount), git.CommitsRangeSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + return wikiRepo, entry +} + +func renderEditPage(ctx *context.Context) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + if !git.IsErrNotExist(err) { + ctx.ServerError("GetBranchCommit", err) + } + return + } + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + // get requested pagename + pageName := wiki_service.NormalizeWikiName(ctx.Params(":page")) + if len(pageName) == 0 { + pageName = "Home" + } + ctx.Data["PageURL"] = wiki_service.NameToSubURL(pageName) + ctx.Data["old_title"] = pageName + ctx.Data["Title"] = pageName + ctx.Data["title"] = pageName + ctx.Data["RequireHighlightJS"] = true + + //lookup filename in wiki - get filecontent, gitTree entry , real filename + data, entry, _, noEntry := wikiContentsByName(ctx, commit, pageName) + if noEntry { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/_pages") + } + if entry == nil || ctx.Written() { + return + } + + ctx.Data["content"] = string(data) + ctx.Data["sidebarPresent"] = false + ctx.Data["sidebarContent"] = "" + ctx.Data["footerPresent"] = false + ctx.Data["footerContent"] = "" +} + +// Wiki renders single wiki page +func Wiki(ctx *context.Context) { + ctx.Data["PageIsWiki"] = true + ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(models.UnitTypeWiki) && !ctx.Repo.Repository.IsArchived + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + wikiRepo, entry := renderViewPage(ctx) + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return + } + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + if entry == nil { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + wikiPath := entry.Name() + if markup.Type(wikiPath) != markdown.MarkupName { + ext := strings.ToUpper(filepath.Ext(wikiPath)) + ctx.Data["FormatWarning"] = fmt.Sprintf("%s rendering is not supported at the moment. Rendered as Markdown.", ext) + } + // Get last change information. + lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + ctx.Data["Author"] = lastCommit.Author + + ctx.HTML(http.StatusOK, tplWikiView) +} + +// WikiRevision renders file revision list of wiki page +func WikiRevision(ctx *context.Context) { + ctx.Data["PageIsWiki"] = true + ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(models.UnitTypeWiki) && !ctx.Repo.Repository.IsArchived + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + wikiRepo, entry := renderRevisionPage(ctx) + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return + } + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + if entry == nil { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + // Get last change information. + wikiPath := entry.Name() + lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + ctx.Data["Author"] = lastCommit.Author + + ctx.HTML(http.StatusOK, tplWikiRevision) +} + +// WikiPages render wiki pages list page +func WikiPages(ctx *context.Context) { + if !ctx.Repo.Repository.HasWiki() { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki") + return + } + + ctx.Data["Title"] = ctx.Tr("repo.wiki.pages") + ctx.Data["PageIsWiki"] = true + ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(models.UnitTypeWiki) && !ctx.Repo.Repository.IsArchived + + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + return + } + + entries, err := commit.ListEntries() + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + + ctx.ServerError("ListEntries", err) + return + } + pages := make([]PageMeta, 0, len(entries)) + for _, entry := range entries { + if !entry.IsRegular() { + continue + } + c, err := wikiRepo.GetCommitByPath(entry.Name()) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + + ctx.ServerError("GetCommit", err) + return + } + wikiName, err := wiki_service.FilenameToName(entry.Name()) + if err != nil { + if models.IsErrWikiInvalidFileName(err) { + continue + } + if wikiRepo != nil { + wikiRepo.Close() + } + + ctx.ServerError("WikiFilenameToName", err) + return + } + pages = append(pages, PageMeta{ + Name: wikiName, + SubURL: wiki_service.NameToSubURL(wikiName), + UpdatedUnix: timeutil.TimeStamp(c.Author.When.Unix()), + }) + } + ctx.Data["Pages"] = pages + + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + ctx.HTML(http.StatusOK, tplWikiPages) +} + +// WikiRaw outputs raw blob requested by user (image for example) +func WikiRaw(ctx *context.Context) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + return + } + } + + providedPath := ctx.Params("*") + + var entry *git.TreeEntry + if commit != nil { + // Try to find a file with that name + entry, err = findEntryForFile(commit, providedPath) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findFile", err) + return + } + + if entry == nil { + // Try to find a wiki page with that name + if strings.HasSuffix(providedPath, ".md") { + providedPath = providedPath[:len(providedPath)-3] + } + + wikiPath := wiki_service.NameToFilename(providedPath) + entry, err = findEntryForFile(commit, wikiPath) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findFile", err) + return + } + } + } + + if entry != nil { + if err = common.ServeBlob(ctx, entry.Blob()); err != nil { + ctx.ServerError("ServeBlob", err) + } + return + } + + ctx.NotFound("findEntryForFile", nil) +} + +// NewWiki render wiki create page +func NewWiki(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.wiki.new_page") + ctx.Data["PageIsWiki"] = true + ctx.Data["RequireSimpleMDE"] = true + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["title"] = "Home" + } + + ctx.HTML(http.StatusOK, tplWikiNew) +} + +// NewWikiPost response for wiki create request +func NewWikiPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewWikiForm) + ctx.Data["Title"] = ctx.Tr("repo.wiki.new_page") + ctx.Data["PageIsWiki"] = true + ctx.Data["RequireSimpleMDE"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplWikiNew) + return + } + + if util.IsEmptyString(form.Title) { + ctx.RenderWithErr(ctx.Tr("repo.issues.new.title_empty"), tplWikiNew, form) + return + } + + wikiName := wiki_service.NormalizeWikiName(form.Title) + + if len(form.Message) == 0 { + form.Message = ctx.Tr("repo.editor.add", form.Title) + } + + if err := wiki_service.AddWikiPage(ctx.User, ctx.Repo.Repository, wikiName, form.Content, form.Message); err != nil { + if models.IsErrWikiReservedName(err) { + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.wiki.reserved_page", wikiName), tplWikiNew, &form) + } else if models.IsErrWikiAlreadyExist(err) { + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.wiki.page_already_exists"), tplWikiNew, &form) + } else { + ctx.ServerError("AddWikiPage", err) + } + return + } + + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.NameToSubURL(wikiName)) +} + +// EditWiki render wiki modify page +func EditWiki(ctx *context.Context) { + ctx.Data["PageIsWiki"] = true + ctx.Data["PageIsWikiEdit"] = true + ctx.Data["RequireSimpleMDE"] = true + + if !ctx.Repo.Repository.HasWiki() { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki") + return + } + + renderEditPage(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplWikiNew) +} + +// EditWikiPost response for wiki modify request +func EditWikiPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewWikiForm) + ctx.Data["Title"] = ctx.Tr("repo.wiki.new_page") + ctx.Data["PageIsWiki"] = true + ctx.Data["RequireSimpleMDE"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplWikiNew) + return + } + + oldWikiName := wiki_service.NormalizeWikiName(ctx.Params(":page")) + newWikiName := wiki_service.NormalizeWikiName(form.Title) + + if len(form.Message) == 0 { + form.Message = ctx.Tr("repo.editor.update", form.Title) + } + + if err := wiki_service.EditWikiPage(ctx.User, ctx.Repo.Repository, oldWikiName, newWikiName, form.Content, form.Message); err != nil { + ctx.ServerError("EditWikiPage", err) + return + } + + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.NameToSubURL(newWikiName)) +} + +// DeleteWikiPagePost delete wiki page +func DeleteWikiPagePost(ctx *context.Context) { + wikiName := wiki_service.NormalizeWikiName(ctx.Params(":page")) + if len(wikiName) == 0 { + wikiName = "Home" + } + + if err := wiki_service.DeleteWikiPage(ctx.User, ctx.Repo.Repository, wikiName); err != nil { + ctx.ServerError("DeleteWikiPage", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/wiki/", + }) +} diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go new file mode 100644 index 0000000000..8934a6619f --- /dev/null +++ b/routers/web/repo/wiki_test.go @@ -0,0 +1,215 @@ +// 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 repo + +import ( + "io/ioutil" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + wiki_service "code.gitea.io/gitea/services/wiki" + + "github.com/stretchr/testify/assert" +) + +const content = "Wiki contents for unit tests" +const message = "Wiki commit message for unit tests" + +func wikiEntry(t *testing.T, repo *models.Repository, wikiName string) *git.TreeEntry { + wikiRepo, err := git.OpenRepository(repo.WikiPath()) + assert.NoError(t, err) + defer wikiRepo.Close() + commit, err := wikiRepo.GetBranchCommit("master") + assert.NoError(t, err) + entries, err := commit.ListEntries() + assert.NoError(t, err) + for _, entry := range entries { + if entry.Name() == wiki_service.NameToFilename(wikiName) { + return entry + } + } + return nil +} + +func wikiContent(t *testing.T, repo *models.Repository, wikiName string) string { + entry := wikiEntry(t, repo, wikiName) + if !assert.NotNil(t, entry) { + return "" + } + reader, err := entry.Blob().DataAsync() + assert.NoError(t, err) + defer reader.Close() + bytes, err := ioutil.ReadAll(reader) + assert.NoError(t, err) + return string(bytes) +} + +func assertWikiExists(t *testing.T, repo *models.Repository, wikiName string) { + assert.NotNil(t, wikiEntry(t, repo, wikiName)) +} + +func assertWikiNotExists(t *testing.T, repo *models.Repository, wikiName string) { + assert.Nil(t, wikiEntry(t, repo, wikiName)) +} + +func assertPagesMetas(t *testing.T, expectedNames []string, metas interface{}) { + pageMetas, ok := metas.([]PageMeta) + if !assert.True(t, ok) { + return + } + if !assert.Len(t, pageMetas, len(expectedNames)) { + return + } + for i, pageMeta := range pageMetas { + assert.EqualValues(t, expectedNames[i], pageMeta.Name) + } +} + +func TestWiki(t *testing.T) { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/_pages") + ctx.SetParams(":page", "Home") + test.LoadRepo(t, ctx, 1) + Wiki(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, "Home", ctx.Data["Title"]) + assertPagesMetas(t, []string{"Home", "Page With Image", "Page With Spaced Name"}, ctx.Data["Pages"]) +} + +func TestWikiPages(t *testing.T) { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/_pages") + test.LoadRepo(t, ctx, 1) + WikiPages(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assertPagesMetas(t, []string{"Home", "Page With Image", "Page With Spaced Name"}, ctx.Data["Pages"]) +} + +func TestNewWiki(t *testing.T) { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/_new") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + NewWiki(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, ctx.Tr("repo.wiki.new_page"), ctx.Data["Title"]) +} + +func TestNewWikiPost(t *testing.T) { + for _, title := range []string{ + "New page", + "&&&&", + } { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/_new") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: title, + Content: content, + Message: message, + }) + NewWikiPost(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assertWikiExists(t, ctx.Repo.Repository, title) + assert.Equal(t, wikiContent(t, ctx.Repo.Repository, title), content) + } +} + +func TestNewWikiPost_ReservedName(t *testing.T) { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/_new") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: "_edit", + Content: content, + Message: message, + }) + NewWikiPost(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page"), ctx.Flash.ErrorMsg) + assertWikiNotExists(t, ctx.Repo.Repository, "_edit") +} + +func TestEditWiki(t *testing.T) { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/_edit/Home") + ctx.SetParams(":page", "Home") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + EditWiki(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, "Home", ctx.Data["Title"]) + assert.Equal(t, wikiContent(t, ctx.Repo.Repository, "Home"), ctx.Data["content"]) +} + +func TestEditWikiPost(t *testing.T) { + for _, title := range []string{ + "Home", + "New/<page>", + } { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1/wiki/_new/Home") + ctx.SetParams(":page", "Home") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: title, + Content: content, + Message: message, + }) + EditWikiPost(ctx) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + assertWikiExists(t, ctx.Repo.Repository, title) + assert.Equal(t, wikiContent(t, ctx.Repo.Repository, title), content) + if title != "Home" { + assertWikiNotExists(t, ctx.Repo.Repository, "Home") + } + } +} + +func TestDeleteWikiPagePost(t *testing.T) { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/Home/delete") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + DeleteWikiPagePost(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assertWikiNotExists(t, ctx.Repo.Repository, "Home") +} + +func TestWikiRaw(t *testing.T) { + for filepath, filetype := range map[string]string{ + "jpeg.jpg": "image/jpeg", + "images/jpeg.jpg": "image/jpeg", + "Page With Spaced Name": "text/plain; charset=utf-8", + "Page-With-Spaced-Name": "text/plain; charset=utf-8", + "Page With Spaced Name.md": "text/plain; charset=utf-8", + "Page-With-Spaced-Name.md": "text/plain; charset=utf-8", + } { + models.PrepareTestEnv(t) + + ctx := test.MockContext(t, "user2/repo1/wiki/raw/"+filepath) + ctx.SetParams("*", filepath) + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + WikiRaw(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, filetype, ctx.Resp.Header().Get("Content-Type")) + } +} diff --git a/routers/web/swagger_json.go b/routers/web/swagger_json.go new file mode 100644 index 0000000000..82d72698c6 --- /dev/null +++ b/routers/web/swagger_json.go @@ -0,0 +1,26 @@ +// Copyright 2020 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 web + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" +) + +// tplSwaggerV1Json swagger v1 json template +const tplSwaggerV1Json base.TplName = "swagger/v1_json" + +// SwaggerV1Json render swagger v1 json +func SwaggerV1Json(ctx *context.Context) { + t := ctx.Render.TemplateLookup(string(tplSwaggerV1Json)) + ctx.Resp.Header().Set("Content-Type", "application/json") + if err := t.Execute(ctx.Resp, ctx.Data); err != nil { + log.Error("%v", err) + ctx.Error(http.StatusInternalServerError) + } +} diff --git a/routers/web/user/auth.go b/routers/web/user/auth.go new file mode 100644 index 0000000000..827b7cdef0 --- /dev/null +++ b/routers/web/user/auth.go @@ -0,0 +1,1769 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 user + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/oauth2" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/hcaptcha" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/password" + "code.gitea.io/gitea/modules/recaptcha" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + + "github.com/markbates/goth" + "github.com/tstranex/u2f" +) + +const ( + // tplMustChangePassword template for updating a user's password + tplMustChangePassword = "user/auth/change_passwd" + // tplSignIn template for sign in page + tplSignIn base.TplName = "user/auth/signin" + // tplSignUp template path for sign up page + tplSignUp base.TplName = "user/auth/signup" + // TplActivate template path for activate user + TplActivate base.TplName = "user/auth/activate" + tplForgotPassword base.TplName = "user/auth/forgot_passwd" + tplResetPassword base.TplName = "user/auth/reset_passwd" + tplTwofa base.TplName = "user/auth/twofa" + tplTwofaScratch base.TplName = "user/auth/twofa_scratch" + tplLinkAccount base.TplName = "user/auth/link_account" + tplU2F base.TplName = "user/auth/u2f" +) + +// AutoSignIn reads cookie and try to auto-login. +func AutoSignIn(ctx *context.Context) (bool, error) { + if !models.HasEngine { + return false, nil + } + + uname := ctx.GetCookie(setting.CookieUserName) + if len(uname) == 0 { + return false, nil + } + + isSucceed := false + defer func() { + if !isSucceed { + log.Trace("auto-login cookie cleared: %s", uname) + ctx.DeleteCookie(setting.CookieUserName) + ctx.DeleteCookie(setting.CookieRememberName) + } + }() + + u, err := models.GetUserByName(uname) + if err != nil { + if !models.IsErrUserNotExist(err) { + return false, fmt.Errorf("GetUserByName: %v", err) + } + return false, nil + } + + if val, ok := ctx.GetSuperSecureCookie( + base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name { + return false, nil + } + + isSucceed = true + + // Set session IDs + if err := ctx.Session.Set("uid", u.ID); err != nil { + return false, err + } + if err := ctx.Session.Set("uname", u.Name); err != nil { + return false, err + } + if err := ctx.Session.Release(); err != nil { + return false, err + } + + middleware.DeleteCSRFCookie(ctx.Resp) + return true, nil +} + +func checkAutoLogin(ctx *context.Context) bool { + // Check auto-login. + isSucceed, err := AutoSignIn(ctx) + if err != nil { + ctx.ServerError("AutoSignIn", err) + return true + } + + redirectTo := ctx.Query("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } else { + redirectTo = ctx.GetCookie("redirect_to") + } + + if isSucceed { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo, setting.AppSubURL+string(setting.LandingPageURL)) + return true + } + + return false +} + +// SignIn render sign in page +func SignIn(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names + ctx.Data["OAuth2Providers"] = oauth2Providers + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true + ctx.Data["EnableSSPI"] = models.IsSSPIEnabled() + + ctx.HTML(http.StatusOK, tplSignIn) +} + +// SignInPost response for sign in request +func SignInPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names + ctx.Data["OAuth2Providers"] = oauth2Providers + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true + ctx.Data["EnableSSPI"] = models.IsSSPIEnabled() + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignIn) + return + } + + form := web.GetForm(ctx).(*forms.SignInForm) + u, err := models.UserSignIn(form.UserName, form.Password) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form) + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if models.IsErrEmailAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSignIn, &form) + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if models.IsErrUserProhibitLogin(err) { + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if models.IsErrUserInactive(err) { + if setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, TplActivate) + } else { + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + // If this user is enrolled in 2FA, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + _, err = models.GetTwoFactorByUID(u.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + handleSignIn(ctx, u, form.Remember) + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + + // User needs to use 2FA, save data and redirect to 2FA page. + if err := ctx.Session.Set("twofaUid", u.ID); err != nil { + ctx.ServerError("UserSignIn: Unable to set twofaUid in session", err) + return + } + if err := ctx.Session.Set("twofaRemember", form.Remember); err != nil { + ctx.ServerError("UserSignIn: Unable to set twofaRemember in session", err) + return + } + if err := ctx.Session.Release(); err != nil { + ctx.ServerError("UserSignIn: Unable to save session", err) + return + } + + regs, err := models.GetU2FRegistrationsByUID(u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/u2f") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// TwoFactor shows the user a two-factor authentication page. +func TwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + ctx.HTML(http.StatusOK, tplTwofa) +} + +// TwoFactorPost validates a user's two-factor authentication token. +func TwoFactorPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + ctx.Data["Title"] = ctx.Tr("twofa") + + // Ensure user is in a 2FA session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + id := idSess.(int64) + twofa, err := models.GetTwoFactorByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + ok, err := twofa.ValidateTOTP(form.Passcode) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ok && twofa.LastUsedPasscode != form.Passcode { + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := models.GetUserByID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ctx.Session.Get("linkAccount") != nil { + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + err = externalaccount.LinkAccountToUser(u, gothUser.(goth.User)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + } + + twofa.LastUsedPasscode = form.Passcode + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignIn(ctx, u, remember) + return + } + + ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{}) +} + +// TwoFactorScratch shows the scratch code form for two-factor authentication. +func TwoFactorScratch(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa_scratch") + + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + ctx.HTML(http.StatusOK, tplTwofaScratch) +} + +// TwoFactorScratchPost validates and invalidates a user's two-factor scratch token. +func TwoFactorScratchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorScratchAuthForm) + ctx.Data["Title"] = ctx.Tr("twofa_scratch") + + // Ensure user is in a 2FA session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + id := idSess.(int64) + twofa, err := models.GetTwoFactorByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + if twofa.VerifyScratchToken(form.Token) { + // Invalidate the scratch token. + _, err = twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := models.GetUserByID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + + ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{}) +} + +// U2F shows the U2F login page +func U2F(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + ctx.Data["RequireU2F"] = true + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + + ctx.HTML(http.StatusOK, tplU2F) +} + +// U2FChallenge submits a sign challenge to the browser +func U2FChallenge(ctx *context.Context) { + // Ensure user is in a U2F session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + id := idSess.(int64) + regs, err := models.GetU2FRegistrationsByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if len(regs) == 0 { + ctx.ServerError("UserSignIn", errors.New("no device registered")) + return + } + challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets) + if err != nil { + ctx.ServerError("u2f.NewChallenge", err) + return + } + if err := ctx.Session.Set("u2fChallenge", challenge); err != nil { + ctx.ServerError("UserSignIn: unable to set u2fChallenge in session", err) + return + } + if err := ctx.Session.Release(); err != nil { + ctx.ServerError("UserSignIn: unable to store session", err) + } + + ctx.JSON(http.StatusOK, challenge.SignRequest(regs.ToRegistrations())) +} + +// U2FSign authenticates the user by signResp +func U2FSign(ctx *context.Context) { + signResp := web.GetForm(ctx).(*u2f.SignResponse) + challSess := ctx.Session.Get("u2fChallenge") + idSess := ctx.Session.Get("twofaUid") + if challSess == nil || idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + challenge := challSess.(*u2f.Challenge) + id := idSess.(int64) + regs, err := models.GetU2FRegistrationsByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + for _, reg := range regs { + r, err := reg.Parse() + if err != nil { + log.Fatal("parsing u2f registration: %v", err) + continue + } + newCounter, authErr := r.Authenticate(*signResp, *challenge, reg.Counter) + if authErr == nil { + reg.Counter = newCounter + user, err := models.GetUserByID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + remember := ctx.Session.Get("twofaRemember").(bool) + if err := reg.UpdateCounter(); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ctx.Session.Get("linkAccount") != nil { + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + err = externalaccount.LinkAccountToUser(user, gothUser.(goth.User)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + } + redirect := handleSignInFull(ctx, user, remember, false) + if redirect == "" { + redirect = setting.AppSubURL + "/" + } + ctx.PlainText(200, []byte(redirect)) + return + } + } + ctx.Error(http.StatusUnauthorized) +} + +// This handles the final part of the sign-in process of the user. +func handleSignIn(ctx *context.Context, u *models.User, remember bool) { + handleSignInFull(ctx, u, remember, true) +} + +func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyRedirect bool) string { + if remember { + days := 86400 * setting.LogInRememberDays + ctx.SetCookie(setting.CookieUserName, u.Name, days) + ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), + setting.CookieRememberName, u.Name, days) + } + + _ = ctx.Session.Delete("openid_verified_uri") + _ = ctx.Session.Delete("openid_signin_remember") + _ = ctx.Session.Delete("openid_determined_email") + _ = ctx.Session.Delete("openid_determined_username") + _ = ctx.Session.Delete("twofaUid") + _ = ctx.Session.Delete("twofaRemember") + _ = ctx.Session.Delete("u2fChallenge") + _ = ctx.Session.Delete("linkAccount") + if err := ctx.Session.Set("uid", u.ID); err != nil { + log.Error("Error setting uid %d in session: %v", u.ID, err) + } + if err := ctx.Session.Set("uname", u.Name); err != nil { + log.Error("Error setting uname %s session: %v", u.Name, err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Unable to store session: %v", err) + } + + // Language setting of the user overwrites the one previously set + // If the user does not have a locale set, we save the current one. + if len(u.Language) == 0 { + u.Language = ctx.Locale.Language() + if err := models.UpdateUserCols(u, "language"); err != nil { + log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", u.ID, u.Language)) + return setting.AppSubURL + "/" + } + } + + middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + + // Clear whatever CSRF has right now, force to generate a new one + middleware.DeleteCSRFCookie(ctx.Resp) + + // Register last login + u.SetLastLogin() + if err := models.UpdateUserCols(u, "last_login_unix"); err != nil { + ctx.ServerError("UpdateUserCols", err) + return setting.AppSubURL + "/" + } + + if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { + middleware.DeleteRedirectToCookie(ctx.Resp) + if obeyRedirect { + ctx.RedirectToFirst(redirectTo) + } + return redirectTo + } + + if obeyRedirect { + ctx.Redirect(setting.AppSubURL + "/") + } + return setting.AppSubURL + "/" +} + +// SignInOAuth handles the OAuth2 login buttons +func SignInOAuth(ctx *context.Context) { + provider := ctx.Params(":provider") + + loginSource, err := models.GetActiveOAuth2LoginSourceByName(provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user + user, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp) + if err == nil && user != nil { + // we got the user without going through the whole OAuth2 authentication flow again + handleOAuth2SignIn(ctx, user, gothUser) + return + } + + if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil { + if strings.Contains(err.Error(), "no provider for ") { + if err = models.ResetOAuth2(); err != nil { + ctx.ServerError("SignIn", err) + return + } + if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil { + ctx.ServerError("SignIn", err) + } + return + } + ctx.ServerError("SignIn", err) + } + // redirect is done in oauth2.Auth +} + +// SignInOAuthCallback handles the callback from the given provider +func SignInOAuthCallback(ctx *context.Context) { + provider := ctx.Params(":provider") + + // first look if the provider is still active + loginSource, err := models.GetActiveOAuth2LoginSourceByName(provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + if loginSource == nil { + ctx.ServerError("SignIn", errors.New("No valid provider found, check configured callback url in provider")) + return + } + + u, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp) + + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if u == nil { + if !(setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration) && setting.OAuth2Client.EnableAutoRegistration { + // create new user with details from oauth2 provider + var missingFields []string + if gothUser.UserID == "" { + missingFields = append(missingFields, "sub") + } + if gothUser.Email == "" { + missingFields = append(missingFields, "email") + } + if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" { + missingFields = append(missingFields, "nickname") + } + if len(missingFields) > 0 { + log.Error("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields) + if loginSource.IsOAuth2() && loginSource.OAuth2().Provider == "openidConnect" { + log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields") + } + err = fmt.Errorf("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields) + ctx.ServerError("CreateUser", err) + return + } + u = &models.User{ + Name: getUserName(&gothUser), + FullName: gothUser.Name, + Email: gothUser.Email, + IsActive: !setting.OAuth2Client.RegisterEmailConfirm, + LoginType: models.LoginOAuth2, + LoginSource: loginSource.ID, + LoginName: gothUser.UserID, + } + + if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { + // error already handled + return + } + } else { + // no existing user is found, request attach or new account + showLinkingLogin(ctx, gothUser) + return + } + } + + handleOAuth2SignIn(ctx, u, gothUser) +} + +func getUserName(gothUser *goth.User) string { + switch setting.OAuth2Client.Username { + case setting.OAuth2UsernameEmail: + return strings.Split(gothUser.Email, "@")[0] + case setting.OAuth2UsernameNickname: + return gothUser.NickName + default: // OAuth2UsernameUserid + return gothUser.UserID + } +} + +func showLinkingLogin(ctx *context.Context, gothUser goth.User) { + if err := ctx.Session.Set("linkAccountGothUser", gothUser); err != nil { + log.Error("Error setting linkAccountGothUser in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + ctx.Redirect(setting.AppSubURL + "/user/link_account") +} + +func updateAvatarIfNeed(url string, u *models.User) { + if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { + resp, err := http.Get(url) + if err == nil { + defer func() { + _ = resp.Body.Close() + }() + } + // ignore any error + if err == nil && resp.StatusCode == http.StatusOK { + data, err := ioutil.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1)) + if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize { + _ = u.UploadAvatar(data) + } + } + } +} + +func handleOAuth2SignIn(ctx *context.Context, u *models.User, gothUser goth.User) { + updateAvatarIfNeed(gothUser.AvatarURL, u) + + // If this user is enrolled in 2FA, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + _, err := models.GetTwoFactorByUID(u.ID) + if err != nil { + if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserSignIn", err) + return + } + + if err := ctx.Session.Set("uid", u.ID); err != nil { + log.Error("Error setting uid in session: %v", err) + } + if err := ctx.Session.Set("uname", u.Name); err != nil { + log.Error("Error setting uname in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + // Clear whatever CSRF has right now, force to generate a new one + middleware.DeleteCSRFCookie(ctx.Resp) + + // Register last login + u.SetLastLogin() + if err := models.UpdateUserCols(u, "last_login_unix"); err != nil { + ctx.ServerError("UpdateUserCols", err) + return + } + + // update external user information + if err := models.UpdateExternalUser(u, gothUser); err != nil { + log.Error("UpdateExternalUser failed: %v", err) + } + + if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") + return + } + + // User needs to use 2FA, save data and redirect to 2FA page. + if err := ctx.Session.Set("twofaUid", u.ID); err != nil { + log.Error("Error setting twofaUid in session: %v", err) + } + if err := ctx.Session.Set("twofaRemember", false); err != nil { + log.Error("Error setting twofaRemember in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + // If U2F is enrolled -> Redirect to U2F instead + regs, err := models.GetU2FRegistrationsByUID(u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/u2f") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful +// login the user +func oAuth2UserLoginCallback(loginSource *models.LoginSource, request *http.Request, response http.ResponseWriter) (*models.User, goth.User, error) { + gothUser, err := oauth2.ProviderCallback(loginSource.Name, request, response) + + if err != nil { + if err.Error() == "securecookie: the value is too long" { + log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength) + err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength) + } + return nil, goth.User{}, err + } + + user := &models.User{ + LoginName: gothUser.UserID, + LoginType: models.LoginOAuth2, + LoginSource: loginSource.ID, + } + + hasUser, err := models.GetUser(user) + if err != nil { + return nil, goth.User{}, err + } + + if hasUser { + return user, gothUser, nil + } + + // search in external linked users + externalLoginUser := &models.ExternalLoginUser{ + ExternalID: gothUser.UserID, + LoginSourceID: loginSource.ID, + } + hasUser, err = models.GetExternalLogin(externalLoginUser) + if err != nil { + return nil, goth.User{}, err + } + if hasUser { + user, err = models.GetUserByID(externalLoginUser.UserID) + return user, gothUser, err + } + + // no user found to login + return nil, gothUser, nil + +} + +// LinkAccount shows the page where the user can decide to login or create a new account +func LinkAccount(ctx *context.Context) { + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + gu, _ := gothUser.(goth.User) + uname := getUserName(&gu) + email := gu.Email + ctx.Data["user_name"] = uname + ctx.Data["email"] = email + + if len(email) != 0 { + u, err := models.GetUserByEmail(email) + if err != nil && !models.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } else if len(uname) != 0 { + u, err := models.GetUserByName(uname) + if err != nil && !models.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } + + ctx.HTML(http.StatusOK, tplLinkAccount) +} + +// LinkAccountPostSignIn handle the coupling of external account with another account using signIn +func LinkAccountPostSignIn(ctx *context.Context) { + signInForm := web.GetForm(ctx).(*forms.SignInForm) + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["LinkAccountModeSignIn"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplLinkAccount) + return + } + + u, err := models.UserSignIn(signInForm.UserName, signInForm.Password) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Data["user_exists"] = true + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplLinkAccount, &signInForm) + } else { + ctx.ServerError("UserLinkAccount", err) + } + return + } + + linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) +} + +func linkAccount(ctx *context.Context, u *models.User, gothUser goth.User, remember bool) { + updateAvatarIfNeed(gothUser.AvatarURL, u) + + // If this user is enrolled in 2FA, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + _, err := models.GetTwoFactorByUID(u.ID) + if err != nil { + if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserLinkAccount", err) + return + } + + err = externalaccount.LinkAccountToUser(u, gothUser) + if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + + handleSignIn(ctx, u, remember) + return + } + + // User needs to use 2FA, save data and redirect to 2FA page. + if err := ctx.Session.Set("twofaUid", u.ID); err != nil { + log.Error("Error setting twofaUid in session: %v", err) + } + if err := ctx.Session.Set("twofaRemember", remember); err != nil { + log.Error("Error setting twofaRemember in session: %v", err) + } + if err := ctx.Session.Set("linkAccount", true); err != nil { + log.Error("Error setting linkAccount in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + // If U2F is enrolled -> Redirect to U2F instead + regs, err := models.GetU2FRegistrationsByUID(u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/u2f") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// LinkAccountPostRegister handle the creation of a new account for an external account using signUp +func LinkAccountPostRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RegisterForm) + // TODO Make insecure passwords optional for local accounts also, + // once email-based Second-Factor Auth is available + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["LinkAccountModeRegister"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUserInterface := ctx.Session.Get("linkAccountGothUser") + if gothUserInterface == nil { + ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) + return + } + gothUser, ok := gothUserInterface.(goth.User) + if !ok { + ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface)) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplLinkAccount) + return + } + + if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha { + var valid bool + var err error + switch setting.Service.CaptchaType { + case setting.ImageCaptcha: + valid = context.GetImageCaptcha().VerifyReq(ctx.Req) + case setting.ReCaptcha: + valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse) + case setting.HCaptcha: + valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse) + default: + ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) + return + } + if err != nil { + log.Debug("%s", err.Error()) + } + + if !valid { + ctx.Data["Err_Captcha"] = true + ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplLinkAccount, &form) + return + } + } + + if !form.IsEmailDomainAllowed() { + ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form) + return + } + + if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword { + // In models.User an empty password is classed as not set, so we set form.Password to empty. + // Eventually the database should be changed to indicate "Second Factor"-enabled accounts + // (accounts that do not introduce the security vulnerabilities of a password). + // If a user decides to circumvent second-factor security, and purposefully create a password, + // they can still do so using the "Recover Account" option. + form.Password = "" + } else { + if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplLinkAccount, &form) + return + } + if len(strings.TrimSpace(form.Password)) > 0 && len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form) + return + } + } + + loginSource, err := models.GetActiveOAuth2LoginSourceByName(gothUser.Provider) + if err != nil { + ctx.ServerError("CreateUser", err) + } + + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm), + LoginType: models.LoginOAuth2, + LoginSource: loginSource.ID, + LoginName: gothUser.UserID, + } + + if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, &gothUser, false) { + // error already handled + return + } + + ctx.Redirect(setting.AppSubURL + "/user/login") +} + +// HandleSignOut resets the session and sets the cookies +func HandleSignOut(ctx *context.Context) { + _ = ctx.Session.Flush() + _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) + ctx.DeleteCookie(setting.CookieUserName) + ctx.DeleteCookie(setting.CookieRememberName) + middleware.DeleteCSRFCookie(ctx.Resp) + middleware.DeleteLocaleCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp) +} + +// SignOut sign out from login status +func SignOut(ctx *context.Context) { + if ctx.User != nil { + eventsource.GetManager().SendMessageBlocking(ctx.User.ID, &eventsource.Event{ + Name: "logout", + Data: ctx.Session.ID(), + }) + } + HandleSignOut(ctx) + ctx.Redirect(setting.AppSubURL + "/") +} + +// SignUp render the register page +func SignUp(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_up") + + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["PageIsSignUp"] = true + + //Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration + + ctx.HTML(http.StatusOK, tplSignUp) +} + +// SignUpPost response for sign up information submission +func SignUpPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RegisterForm) + ctx.Data["Title"] = ctx.Tr("sign_up") + + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["PageIsSignUp"] = true + + //Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true + if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignUp) + return + } + + if setting.Service.EnableCaptcha { + var valid bool + var err error + switch setting.Service.CaptchaType { + case setting.ImageCaptcha: + valid = context.GetImageCaptcha().VerifyReq(ctx.Req) + case setting.ReCaptcha: + valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse) + case setting.HCaptcha: + valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse) + default: + ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) + return + } + if err != nil { + log.Debug("%s", err.Error()) + } + + if !valid { + ctx.Data["Err_Captcha"] = true + ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplSignUp, &form) + return + } + } + + if !form.IsEmailDomainAllowed() { + ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form) + return + } + + if form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplSignUp, &form) + return + } + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form) + return + } + if !password.IsComplexEnough(form.Password) { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx), tplSignUp, &form) + return + } + pwned, err := password.IsPwned(ctx, form.Password) + if pwned { + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(errMsg, tplSignUp, &form) + return + } + + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm), + } + + if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, false) { + // error already handled + return + } + + ctx.Flash.Success(ctx.Tr("auth.sign_up_successful")) + handleSignInFull(ctx, u, false, true) +} + +// createAndHandleCreatedUser calls createUserInContext and +// then handleUserCreated. +func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form interface{}, u *models.User, gothUser *goth.User, allowLink bool) bool { + if !createUserInContext(ctx, tpl, form, u, gothUser, allowLink) { + return false + } + return handleUserCreated(ctx, u, gothUser) +} + +// createUserInContext creates a user and handles errors within a given context. +// Optionally a template can be specified. +func createUserInContext(ctx *context.Context, tpl base.TplName, form interface{}, u *models.User, gothUser *goth.User, allowLink bool) (ok bool) { + if err := models.CreateUser(u); err != nil { + if allowLink && (models.IsErrUserAlreadyExist(err) || models.IsErrEmailAlreadyUsed(err)) { + if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { + var user *models.User + user = &models.User{Name: u.Name} + hasUser, err := models.GetUser(user) + if !hasUser || err != nil { + user = &models.User{Email: u.Email} + hasUser, err = models.GetUser(user) + if !hasUser || err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + } + + // TODO: probably we should respect 'remember' user's choice... + linkAccount(ctx, user, *gothUser, true) + return // user is already created here, all redirects are handled + } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { + showLinkingLogin(ctx, *gothUser) + return // user will be created only after linking login + } + } + + // handle error without template + if len(tpl) == 0 { + ctx.ServerError("CreateUser", err) + return + } + + // handle error with template + switch { + case models.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tpl, form) + case models.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tpl, form) + case models.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form) + case models.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form) + case models.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tpl, form) + case models.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(models.ErrNameCharsNotAllowed).Name), tpl, form) + default: + ctx.ServerError("CreateUser", err) + } + return + } + log.Trace("Account created: %s", u.Name) + return true +} + +// handleUserCreated does additional steps after a new user is created. +// It auto-sets admin for the only user, updates the optional external user and +// sends a confirmation email if required. +func handleUserCreated(ctx *context.Context, u *models.User, gothUser *goth.User) (ok bool) { + // Auto-set admin for the only user. + if models.CountUsers() == 1 { + u.IsAdmin = true + u.IsActive = true + u.SetLastLogin() + if err := models.UpdateUserCols(u, "is_admin", "is_active", "last_login_unix"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + } + + // update external user information + if gothUser != nil { + if err := models.UpdateExternalUser(u, *gothUser); err != nil { + log.Error("UpdateExternalUser failed: %v", err) + } + } + + // Send confirmation email + if !u.IsActive && u.ID > 1 { + mailer.SendActivateAccountMail(ctx.Locale, u) + + ctx.Data["IsSendRegisterMail"] = true + ctx.Data["Email"] = u.Email + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()) + ctx.HTML(http.StatusOK, TplActivate) + + if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + return + } + + return true +} + +// Activate render activate user page +func Activate(ctx *context.Context) { + code := ctx.Query("code") + + if len(code) == 0 { + ctx.Data["IsActivatePage"] = true + if ctx.User == nil || ctx.User.IsActive { + ctx.NotFound("invalid user", nil) + return + } + // Resend confirmation email. + if setting.Service.RegisterEmailConfirm { + if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) { + ctx.Data["ResendLimited"] = true + } else { + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()) + mailer.SendActivateAccountMail(ctx.Locale, ctx.User) + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + } + } else { + ctx.Data["ServiceNotEnabled"] = true + } + ctx.HTML(http.StatusOK, TplActivate) + return + } + + user := models.VerifyUserActiveCode(code) + // if code is wrong + if user == nil { + ctx.Data["IsActivateFailed"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + // if account is local account, verify password + if user.LoginSource == 0 { + ctx.Data["Code"] = code + ctx.Data["NeedsPassword"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + handleAccountActivation(ctx, user) +} + +// ActivatePost handles account activation with password check +func ActivatePost(ctx *context.Context) { + code := ctx.Query("code") + if len(code) == 0 { + ctx.Redirect(setting.AppSubURL + "/user/activate") + return + } + + user := models.VerifyUserActiveCode(code) + // if code is wrong + if user == nil { + ctx.Data["IsActivateFailed"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + // if account is local account, verify password + if user.LoginSource == 0 { + password := ctx.Query("password") + if len(password) == 0 { + ctx.Data["Code"] = code + ctx.Data["NeedsPassword"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + if !user.ValidatePassword(password) { + ctx.Data["IsActivateFailed"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + } + + handleAccountActivation(ctx, user) +} + +func handleAccountActivation(ctx *context.Context, user *models.User) { + user.IsActive = true + var err error + if user.Rands, err = models.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := models.UpdateUserCols(user, "is_active", "rands"); err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("UpdateUserCols", err) + } else { + ctx.ServerError("UpdateUser", err) + } + return + } + + log.Trace("User activated: %s", user.Name) + + if err := ctx.Session.Set("uid", user.ID); err != nil { + log.Error(fmt.Sprintf("Error setting uid in session: %v", err)) + } + if err := ctx.Session.Set("uname", user.Name); err != nil { + log.Error(fmt.Sprintf("Error setting uname in session: %v", err)) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + ctx.Flash.Success(ctx.Tr("auth.account_activated")) + ctx.Redirect(setting.AppSubURL + "/") +} + +// ActivateEmail render the activate email page +func ActivateEmail(ctx *context.Context) { + code := ctx.Query("code") + emailStr := ctx.Query("email") + + // Verify code. + if email := models.VerifyActiveEmailCode(code, emailStr); email != nil { + if err := email.Activate(); err != nil { + ctx.ServerError("ActivateEmail", err) + } + + log.Trace("Email activated: %s", email.Email) + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + + if u, err := models.GetUserByID(email.UID); err != nil { + log.Warn("GetUserByID: %d", email.UID) + } else { + // Allow user to validate more emails + _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) + } + } + + // FIXME: e-mail verification does not require the user to be logged in, + // so this could be redirecting to the login page. + // Should users be logged in automatically here? (consider 2FA requirements, etc.) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// ForgotPasswd render the forget pasword page +func ForgotPasswd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + ctx.Data["IsResetDisable"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + email := ctx.Query("email") + ctx.Data["Email"] = email + + ctx.Data["IsResetRequest"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +// ForgotPasswdPost response for forget password request +func ForgotPasswdPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + ctx.NotFound("ForgotPasswdPost", nil) + return + } + ctx.Data["IsResetRequest"] = true + + email := ctx.Query("email") + ctx.Data["Email"] = email + + u, err := models.GetUserByEmail(email) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale.Language()) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + ctx.ServerError("user.ResetPasswd(check existence)", err) + return + } + + if !u.IsLocal() && !u.IsOAuth2() { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("auth.non_local_account"), tplForgotPassword, nil) + return + } + + if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) { + ctx.Data["ResendLimited"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + mailer.SendResetPasswordMail(u) + + if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale.Language()) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +func commonResetPassword(ctx *context.Context) (*models.User, *models.TwoFactor) { + code := ctx.Query("code") + + ctx.Data["Title"] = ctx.Tr("auth.reset_password") + ctx.Data["Code"] = code + + if nil != ctx.User { + ctx.Data["user_signed_in"] = true + } + + if len(code) == 0 { + ctx.Flash.Error(ctx.Tr("auth.invalid_code")) + return nil, nil + } + + // Fail early, don't frustrate the user + u := models.VerifyUserActiveCode(code) + if u == nil { + ctx.Flash.Error(ctx.Tr("auth.invalid_code")) + return nil, nil + } + + twofa, err := models.GetTwoFactorByUID(u.ID) + if err != nil { + if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error()) + return nil, nil + } + } else { + ctx.Data["has_two_factor"] = true + ctx.Data["scratch_code"] = ctx.QueryBool("scratch_code") + } + + // Show the user that they are affecting the account that they intended to + ctx.Data["user_email"] = u.Email + + if nil != ctx.User && u.ID != ctx.User.ID { + ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.User.Email, u.Email)) + return nil, nil + } + + return u, twofa +} + +// ResetPasswd render the account recovery page +func ResetPasswd(ctx *context.Context) { + ctx.Data["IsResetForm"] = true + + commonResetPassword(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplResetPassword) +} + +// ResetPasswdPost response from account recovery request +func ResetPasswdPost(ctx *context.Context) { + u, twofa := commonResetPassword(ctx) + if ctx.Written() { + return + } + + if u == nil { + // Flash error has been set + ctx.HTML(http.StatusOK, tplResetPassword) + return + } + + // Validate password length. + passwd := ctx.Query("password") + if len(passwd) < setting.MinPasswordLength { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil) + return + } else if !password.IsComplexEnough(passwd) { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx), tplResetPassword, nil) + return + } else if pwned, err := password.IsPwned(ctx, passwd); pwned || err != nil { + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(errMsg, tplResetPassword, nil) + return + } + + // Handle two-factor + regenerateScratchToken := false + if twofa != nil { + if ctx.QueryBool("scratch_code") { + if !twofa.VerifyScratchToken(ctx.Query("token")) { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Token"] = true + ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplResetPassword, nil) + return + } + regenerateScratchToken = true + } else { + passcode := ctx.Query("passcode") + ok, err := twofa.ValidateTOTP(passcode) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err.Error()) + return + } + if !ok || twofa.LastUsedPasscode == passcode { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Passcode"] = true + ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil) + return + } + + twofa.LastUsedPasscode = passcode + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err) + return + } + } + } + var err error + if u.Rands, err = models.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err = u.SetPassword(passwd); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + u.MustChangePassword = false + if err := models.UpdateUserCols(u, "must_change_password", "passwd", "passwd_hash_algo", "rands", "salt"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + log.Trace("User password reset: %s", u.Name) + ctx.Data["IsResetFailed"] = true + remember := len(ctx.Query("remember")) != 0 + + if regenerateScratchToken { + // Invalidate the scratch token. + _, err = twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + + handleSignInFull(ctx, u, remember, true) +} + +// MustChangePassword renders the page to change a user's password +func MustChangePassword(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + ctx.Data["MustChangePassword"] = true + ctx.HTML(http.StatusOK, tplMustChangePassword) +} + +// MustChangePasswordPost response for updating a user's password after his/her +// account was created by an admin +func MustChangePasswordPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MustChangePasswordForm) + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMustChangePassword) + return + } + u := ctx.User + // Make sure only requests for users who are eligible to change their password via + // this method passes through + if !u.MustChangePassword { + ctx.ServerError("MustUpdatePassword", errors.New("cannot update password.. Please visit the settings page")) + return + } + + if form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplMustChangePassword, &form) + return + } + + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form) + return + } + + var err error + if err = u.SetPassword(form.Password); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + u.MustChangePassword = false + + if err := models.UpdateUserCols(u, "must_change_password", "passwd", "passwd_hash_algo", "salt"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + + log.Trace("User updated password: %s", u.Name) + + if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") +} diff --git a/routers/web/user/auth_openid.go b/routers/web/user/auth_openid.go new file mode 100644 index 0000000000..1a73a08c48 --- /dev/null +++ b/routers/web/user/auth_openid.go @@ -0,0 +1,450 @@ +// 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 user + +import ( + "fmt" + "net/http" + "net/url" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/hcaptcha" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/recaptcha" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSignInOpenID base.TplName = "user/auth/signin_openid" + tplConnectOID base.TplName = "user/auth/signup_openid_connect" + tplSignUpOID base.TplName = "user/auth/signup_openid_register" +) + +// SignInOpenID render sign in page +func SignInOpenID(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + if ctx.Query("openid.return_to") != "" { + signInOpenIDVerify(ctx) + return + } + + // Check auto-login. + isSucceed, err := AutoSignIn(ctx) + if err != nil { + ctx.ServerError("AutoSignIn", err) + return + } + + redirectTo := ctx.Query("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } else { + redirectTo = ctx.GetCookie("redirect_to") + } + + if isSucceed { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + ctx.HTML(http.StatusOK, tplSignInOpenID) +} + +// Check if the given OpenID URI is allowed by blacklist/whitelist +func allowedOpenIDURI(uri string) (err error) { + + // In case a Whitelist is present, URI must be in it + // in order to be accepted + if len(setting.Service.OpenIDWhitelist) != 0 { + for _, pat := range setting.Service.OpenIDWhitelist { + if pat.MatchString(uri) { + return nil // pass + } + } + // must match one of this or be refused + return fmt.Errorf("URI not allowed by whitelist") + } + + // A blacklist match expliclty forbids + for _, pat := range setting.Service.OpenIDBlacklist { + if pat.MatchString(uri) { + return fmt.Errorf("URI forbidden by blacklist") + } + } + + return nil +} + +// SignInOpenIDPost response for openid sign in request +func SignInOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.SignInOpenIDForm) + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignInOpenID) + return + } + + id, err := openid.Normalize(form.Openid) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form) + return + } + form.Openid = id + + log.Trace("OpenID uri: " + id) + + err = allowedOpenIDURI(id) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form) + return + } + + redirectTo := setting.AppURL + "user/login/openid" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + log.Error("Error in OpenID redirect URL: %s, %v", redirectTo, err.Error()) + ctx.RenderWithErr(fmt.Sprintf("Unable to find OpenID provider in %s", redirectTo), tplSignInOpenID, &form) + return + } + + // Request optional nickname and email info + // NOTE: change to `openid.sreg.required` to require it + url += "&openid.ns.sreg=http%3A%2F%2Fopenid.net%2Fextensions%2Fsreg%2F1.1" + url += "&openid.sreg.optional=nickname%2Cemail" + + log.Trace("Form-passed openid-remember: %t", form.Remember) + + if err := ctx.Session.Set("openid_signin_remember", form.Remember); err != nil { + log.Error("SignInOpenIDPost: Could not set openid_signin_remember in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("SignInOpenIDPost: Unable to save changes to the session: %v", err) + } + + ctx.Redirect(url) +} + +// signInOpenIDVerify handles response from OpenID provider +func signInOpenIDVerify(ctx *context.Context) { + + log.Trace("Incoming call to: " + ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + var id, err = openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: " + id) + + /* Now we should seek for the user and log him in, or prompt + * to register if not found */ + + u, err := models.GetUserByOpenID(id) + if err != nil { + if !models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + log.Error("signInOpenIDVerify: %v", err) + } + if u != nil { + log.Trace("User exists, logging in") + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", remember) + handleSignIn(ctx, u, remember) + return + } + + log.Trace("User with openid " + id + " does not exist, should connect or register") + + parsedURL, err := url.Parse(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + values, err := url.ParseQuery(parsedURL.RawQuery) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + email := values.Get("openid.sreg.email") + nickname := values.Get("openid.sreg.nickname") + + log.Trace("User has email=" + email + " and nickname=" + nickname) + + if email != "" { + u, err = models.GetUserByEmail(email) + if err != nil { + if !models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + log.Error("signInOpenIDVerify: %v", err) + } + if u != nil { + log.Trace("Local user " + u.LowerName + " has OpenID provided email " + email) + } + } + + if u == nil && nickname != "" { + u, _ = models.GetUserByName(nickname) + if err != nil { + if !models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + } + if u != nil { + log.Trace("Local user " + u.LowerName + " has OpenID provided nickname " + nickname) + } + } + + if err := ctx.Session.Set("openid_verified_uri", id); err != nil { + log.Error("signInOpenIDVerify: Could not set openid_verified_uri in session: %v", err) + } + if err := ctx.Session.Set("openid_determined_email", email); err != nil { + log.Error("signInOpenIDVerify: Could not set openid_determined_email in session: %v", err) + } + + if u != nil { + nickname = u.LowerName + } + + if err := ctx.Session.Set("openid_determined_username", nickname); err != nil { + log.Error("signInOpenIDVerify: Could not set openid_determined_username in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("signInOpenIDVerify: Unable to save changes to the session: %v", err) + } + + if u != nil || !setting.Service.EnableOpenIDSignUp || setting.Service.AllowOnlyInternalRegistration { + ctx.Redirect(setting.AppSubURL + "/user/openid/connect") + } else { + ctx.Redirect(setting.AppSubURL + "/user/openid/register") + } +} + +// ConnectOpenID shows a form to connect an OpenID URI to an existing account +func ConnectOpenID(ctx *context.Context) { + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID connect" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDConnect"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["OpenID"] = oid + userName, _ := ctx.Session.Get("openid_determined_username").(string) + if userName != "" { + ctx.Data["user_name"] = userName + } + ctx.HTML(http.StatusOK, tplConnectOID) +} + +// ConnectOpenIDPost handles submission of a form to connect an OpenID URI to an existing account +func ConnectOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ConnectOpenIDForm) + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID connect" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDConnect"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["OpenID"] = oid + + u, err := models.UserSignIn(form.UserName, form.Password) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplConnectOID, &form) + } else { + ctx.ServerError("ConnectOpenIDPost", err) + } + return + } + + // add OpenID for the user + userOID := &models.UserOpenID{UID: u.ID, URI: oid} + if err = models.AddUserOpenID(userOID); err != nil { + if models.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplConnectOID, &form) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) + + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", remember) + handleSignIn(ctx, u, remember) +} + +// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI +func RegisterOpenID(ctx *context.Context) { + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID signup" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDRegister"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["OpenID"] = oid + userName, _ := ctx.Session.Get("openid_determined_username").(string) + if userName != "" { + ctx.Data["user_name"] = userName + } + email, _ := ctx.Session.Get("openid_determined_email").(string) + if email != "" { + ctx.Data["email"] = email + } + ctx.HTML(http.StatusOK, tplSignUpOID) +} + +// RegisterOpenIDPost handles submission of a form to create a new user authenticated via an OpenID URI +func RegisterOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.SignUpOpenIDForm) + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + + ctx.Data["Title"] = "OpenID signup" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDRegister"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["OpenID"] = oid + + if setting.Service.AllowOnlyInternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if setting.Service.EnableCaptcha { + var valid bool + var err error + switch setting.Service.CaptchaType { + case setting.ImageCaptcha: + valid = context.GetImageCaptcha().VerifyReq(ctx.Req) + case setting.ReCaptcha: + if err := ctx.Req.ParseForm(); err != nil { + ctx.ServerError("", err) + return + } + valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse) + case setting.HCaptcha: + if err := ctx.Req.ParseForm(); err != nil { + ctx.ServerError("", err) + return + } + valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse) + default: + ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) + return + } + if err != nil { + log.Debug("%s", err.Error()) + } + + if !valid { + ctx.Data["Err_Captcha"] = true + ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplSignUpOID, &form) + return + } + } + + length := setting.MinPasswordLength + if length < 256 { + length = 256 + } + password, err := util.RandomString(int64(length)) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignUpOID, form) + return + } + + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: password, + IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm), + } + if !createUserInContext(ctx, tplSignUpOID, form, u, nil, false) { + // error already handled + return + } + + // add OpenID for the user + userOID := &models.UserOpenID{UID: u.ID, URI: oid} + if err = models.AddUserOpenID(userOID); err != nil { + if models.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplSignUpOID, &form) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + + if !handleUserCreated(ctx, u, nil) { + // error already handled + return + } + + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", remember) + handleSignIn(ctx, u, remember) +} diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go new file mode 100644 index 0000000000..4287589d1a --- /dev/null +++ b/routers/web/user/avatar.go @@ -0,0 +1,98 @@ +// 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 user + +import ( + "errors" + "net/url" + "path" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// Avatar redirect browser to user avatar of requested size +func Avatar(ctx *context.Context) { + userName := ctx.Params(":username") + size, err := strconv.Atoi(ctx.Params(":size")) + if err != nil { + ctx.ServerError("Invalid avatar size", err) + return + } + + log.Debug("Asked avatar for user %v and size %v", userName, size) + + var user *models.User + if strings.ToLower(userName) != "ghost" { + user, err = models.GetUserByName(userName) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.ServerError("Requested avatar for invalid user", err) + } else { + ctx.ServerError("Retrieving user by name", err) + } + return + } + } else { + user = models.NewGhostUser() + } + + ctx.Redirect(user.RealSizedAvatarLink(size)) +} + +// AvatarByEmailHash redirects the browser to the appropriate Avatar link +func AvatarByEmailHash(ctx *context.Context) { + var err error + + hash := ctx.Params(":hash") + if len(hash) == 0 { + ctx.ServerError("invalid avatar hash", errors.New("hash cannot be empty")) + return + } + + var email string + email, err = models.GetEmailForHash(hash) + if err != nil { + ctx.ServerError("invalid avatar hash", err) + return + } + if len(email) == 0 { + ctx.Redirect(models.DefaultAvatarLink()) + return + } + size := ctx.QueryInt("size") + if size == 0 { + size = models.DefaultAvatarSize + } + + var avatarURL *url.URL + + if setting.EnableFederatedAvatar && setting.LibravatarService != nil { + avatarURL, err = models.LibravatarURL(email) + if err != nil { + avatarURL, err = url.Parse(models.DefaultAvatarLink()) + if err != nil { + ctx.ServerError("invalid default avatar url", err) + return + } + } + } else if !setting.DisableGravatar { + copyOfGravatarSourceURL := *setting.GravatarSourceURL + avatarURL = ©OfGravatarSourceURL + avatarURL.Path = path.Join(avatarURL.Path, hash) + } else { + avatarURL, err = url.Parse(models.DefaultAvatarLink()) + if err != nil { + ctx.ServerError("invalid default avatar url", err) + return + } + } + + ctx.Redirect(models.MakeFinalAvatarURL(avatarURL, size)) +} diff --git a/routers/web/user/home.go b/routers/web/user/home.go new file mode 100644 index 0000000000..acf73f82fe --- /dev/null +++ b/routers/web/user/home.go @@ -0,0 +1,913 @@ +// 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 user + +import ( + "bytes" + "fmt" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + + jsoniter "github.com/json-iterator/go" + "github.com/keybase/go-crypto/openpgp" + "github.com/keybase/go-crypto/openpgp/armor" + "xorm.io/builder" +) + +const ( + tplDashboard base.TplName = "user/dashboard/dashboard" + tplIssues base.TplName = "user/dashboard/issues" + tplMilestones base.TplName = "user/dashboard/milestones" + tplProfile base.TplName = "user/profile" +) + +// getDashboardContextUser finds out which context user dashboard is being viewed as . +func getDashboardContextUser(ctx *context.Context) *models.User { + ctxUser := ctx.User + orgName := ctx.Params(":org") + if len(orgName) > 0 { + ctxUser = ctx.Org.Organization + ctx.Data["Teams"] = ctx.Org.Organization.Teams + } + ctx.Data["ContextUser"] = ctxUser + + if err := ctx.User.GetOrganizations(&models.SearchOrganizationsOptions{All: true}); err != nil { + ctx.ServerError("GetOrganizations", err) + return nil + } + ctx.Data["Orgs"] = ctx.User.Orgs + + return ctxUser +} + +// retrieveFeeds loads feeds for the specified user +func retrieveFeeds(ctx *context.Context, options models.GetFeedsOptions) { + actions, err := models.GetFeeds(options) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + userCache := map[int64]*models.User{options.RequestedUser.ID: options.RequestedUser} + if ctx.User != nil { + userCache[ctx.User.ID] = ctx.User + } + for _, act := range actions { + if act.ActUser != nil { + userCache[act.ActUserID] = act.ActUser + } + } + + for _, act := range actions { + repoOwner, ok := userCache[act.Repo.OwnerID] + if !ok { + repoOwner, err = models.GetUserByID(act.Repo.OwnerID) + if err != nil { + if models.IsErrUserNotExist(err) { + continue + } + ctx.ServerError("GetUserByID", err) + return + } + userCache[repoOwner.ID] = repoOwner + } + act.Repo.Owner = repoOwner + } + ctx.Data["Feeds"] = actions +} + +// Dashboard render the dashboard page +func Dashboard(ctx *context.Context) { + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Tr("dashboard") + ctx.Data["PageIsDashboard"] = true + ctx.Data["PageIsNews"] = true + ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum + + if setting.Service.EnableUserHeatmap { + data, err := models.GetUserHeatmapDataByUserTeam(ctxUser, ctx.Org.Team, ctx.User) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUserTeam", err) + return + } + ctx.Data["HeatmapData"] = data + } + + var err error + var mirrors []*models.Repository + if ctxUser.IsOrganization() { + var env models.AccessibleReposEnvironment + if ctx.Org.Team != nil { + env = ctxUser.AccessibleTeamReposEnv(ctx.Org.Team) + } else { + env, err = ctxUser.AccessibleReposEnv(ctx.User.ID) + if err != nil { + ctx.ServerError("AccessibleReposEnv", err) + return + } + } + mirrors, err = env.MirrorRepos() + if err != nil { + ctx.ServerError("env.MirrorRepos", err) + return + } + } else { + mirrors, err = ctxUser.GetMirrorRepositories() + if err != nil { + ctx.ServerError("GetMirrorRepositories", err) + return + } + } + ctx.Data["MaxShowRepoNum"] = setting.UI.User.RepoPagingNum + + if err := models.MirrorRepositoryList(mirrors).LoadAttributes(); err != nil { + ctx.ServerError("MirrorRepositoryList.LoadAttributes", err) + return + } + ctx.Data["MirrorCount"] = len(mirrors) + ctx.Data["Mirrors"] = mirrors + + retrieveFeeds(ctx, models.GetFeedsOptions{ + RequestedUser: ctxUser, + RequestedTeam: ctx.Org.Team, + Actor: ctx.User, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: false, + Date: ctx.Query("date"), + }) + + if ctx.Written() { + return + } + ctx.HTML(http.StatusOK, tplDashboard) +} + +// Milestones render the user milestones page +func Milestones(ctx *context.Context) { + if models.UnitTypeIssues.UnitGlobalDisabled() && models.UnitTypePullRequests.UnitGlobalDisabled() { + log.Debug("Milestones overview page not available as both issues and pull requests are globally disabled") + ctx.Status(404) + return + } + + ctx.Data["Title"] = ctx.Tr("milestones") + ctx.Data["PageIsMilestonesDashboard"] = true + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + repoOpts := models.SearchRepoOptions{ + Actor: ctxUser, + OwnerID: ctxUser.ID, + Private: true, + AllPublic: false, // Include also all public repositories of users and public organisations + AllLimited: false, // Include also all public repositories of limited organisations + HasMilestones: util.OptionalBoolTrue, // Just needs display repos has milestones + } + + if ctxUser.IsOrganization() && ctx.Org.Team != nil { + repoOpts.TeamID = ctx.Org.Team.ID + } + + var ( + userRepoCond = models.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit + repoCond = userRepoCond + repoIDs []int64 + + reposQuery = ctx.Query("repos") + isShowClosed = ctx.Query("state") == "closed" + sortType = ctx.Query("sort") + page = ctx.QueryInt("page") + keyword = strings.Trim(ctx.Query("q"), " ") + ) + + if page <= 1 { + page = 1 + } + + if len(reposQuery) != 0 { + if issueReposQueryPattern.MatchString(reposQuery) { + // remove "[" and "]" from string + reposQuery = reposQuery[1 : len(reposQuery)-1] + //for each ID (delimiter ",") add to int to repoIDs + + for _, rID := range strings.Split(reposQuery, ",") { + // Ensure nonempty string entries + if rID != "" && rID != "0" { + rIDint64, err := strconv.ParseInt(rID, 10, 64) + // If the repo id specified by query is not parseable or not accessible by user, just ignore it. + if err == nil { + repoIDs = append(repoIDs, rIDint64) + } + } + } + if len(repoIDs) > 0 { + // Don't just let repoCond = builder.In("id", repoIDs) because user may has no permission on repoIDs + // But the original repoCond has a limitation + repoCond = repoCond.And(builder.In("id", repoIDs)) + } + } else { + log.Warn("issueReposQueryPattern not match with query") + } + } + + counts, err := models.CountMilestonesByRepoCondAndKw(userRepoCond, keyword, isShowClosed) + if err != nil { + ctx.ServerError("CountMilestonesByRepoIDs", err) + return + } + + milestones, err := models.SearchMilestones(repoCond, page, isShowClosed, sortType, keyword) + if err != nil { + ctx.ServerError("SearchMilestones", err) + return + } + + showRepos, _, err := models.SearchRepositoryByCondition(&repoOpts, userRepoCond, false) + if err != nil { + ctx.ServerError("SearchRepositoryByCondition", err) + return + } + sort.Sort(showRepos) + + for i := 0; i < len(milestones); { + for _, repo := range showRepos { + if milestones[i].RepoID == repo.ID { + milestones[i].Repo = repo + break + } + } + if milestones[i].Repo == nil { + log.Warn("Cannot find milestone %d 's repository %d", milestones[i].ID, milestones[i].RepoID) + milestones = append(milestones[:i], milestones[i+1:]...) + continue + } + + milestones[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: milestones[i].Repo.Link(), + Metas: milestones[i].Repo.ComposeMetas(), + }, milestones[i].Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + if milestones[i].Repo.IsTimetrackerEnabled() { + err := milestones[i].LoadTotalTrackedTime() + if err != nil { + ctx.ServerError("LoadTotalTrackedTime", err) + return + } + } + i++ + } + + milestoneStats, err := models.GetMilestonesStatsByRepoCondAndKw(repoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + + var totalMilestoneStats *models.MilestonesStats + if len(repoIDs) == 0 { + totalMilestoneStats = milestoneStats + } else { + totalMilestoneStats, err = models.GetMilestonesStatsByRepoCondAndKw(userRepoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + } + + var pagerCount int + if isShowClosed { + ctx.Data["State"] = "closed" + ctx.Data["Total"] = totalMilestoneStats.ClosedCount + pagerCount = int(milestoneStats.ClosedCount) + } else { + ctx.Data["State"] = "open" + ctx.Data["Total"] = totalMilestoneStats.OpenCount + pagerCount = int(milestoneStats.OpenCount) + } + + ctx.Data["Milestones"] = milestones + ctx.Data["Repos"] = showRepos + ctx.Data["Counts"] = counts + ctx.Data["MilestoneStats"] = milestoneStats + ctx.Data["SortType"] = sortType + ctx.Data["Keyword"] = keyword + if milestoneStats.Total() != totalMilestoneStats.Total() { + ctx.Data["RepoIDs"] = repoIDs + } + ctx.Data["IsShowClosed"] = isShowClosed + + pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "repos", "RepoIDs") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplMilestones) +} + +// Pulls renders the user's pull request overview page +func Pulls(ctx *context.Context) { + if models.UnitTypePullRequests.UnitGlobalDisabled() { + log.Debug("Pull request overview page not available as it is globally disabled.") + ctx.Status(404) + return + } + + ctx.Data["Title"] = ctx.Tr("pull_requests") + ctx.Data["PageIsPulls"] = true + buildIssueOverview(ctx, models.UnitTypePullRequests) +} + +// Issues renders the user's issues overview page +func Issues(ctx *context.Context) { + if models.UnitTypeIssues.UnitGlobalDisabled() { + log.Debug("Issues overview page not available as it is globally disabled.") + ctx.Status(404) + return + } + + ctx.Data["Title"] = ctx.Tr("issues") + ctx.Data["PageIsIssues"] = true + buildIssueOverview(ctx, models.UnitTypeIssues) +} + +// Regexp for repos query +var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`) + +func buildIssueOverview(ctx *context.Context, unitType models.UnitType) { + + // ---------------------------------------------------- + // Determine user; can be either user or organization. + // Return with NotFound or ServerError if unsuccessful. + // ---------------------------------------------------- + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + var ( + viewType string + sortType = ctx.Query("sort") + filterMode = models.FilterModeAll + ) + + // -------------------------------------------------------------------------------- + // Distinguish User from Organization. + // Org: + // - Remember pre-determined viewType string for later. Will be posted to ctx.Data. + // Organization does not have view type and filter mode. + // User: + // - Use ctx.Query("type") to determine filterMode. + // The type is set when clicking for example "assigned to me" on the overview page. + // - Remember either this or a fallback. Will be posted to ctx.Data. + // -------------------------------------------------------------------------------- + + // TODO: distinguish during routing + + viewType = ctx.Query("type") + switch viewType { + case "assigned": + filterMode = models.FilterModeAssign + case "created_by": + filterMode = models.FilterModeCreate + case "mentioned": + filterMode = models.FilterModeMention + case "review_requested": + filterMode = models.FilterModeReviewRequested + case "your_repositories": // filterMode already set to All + default: + viewType = "your_repositories" + } + + // -------------------------------------------------------------------------- + // Build opts (IssuesOptions), which contains filter information. + // Will eventually be used to retrieve issues relevant for the overview page. + // Note: Non-final states of opts are used in-between, namely for: + // - Keyword search + // - Count Issues by repo + // -------------------------------------------------------------------------- + + isPullList := unitType == models.UnitTypePullRequests + opts := &models.IssuesOptions{ + IsPull: util.OptionalBoolOf(isPullList), + SortType: sortType, + IsArchived: util.OptionalBoolFalse, + } + + // Get repository IDs where User/Org/Team has access. + var team *models.Team + if ctx.Org != nil { + team = ctx.Org.Team + } + userRepoIDs, err := getActiveUserRepoIDs(ctxUser, team, unitType) + if err != nil { + ctx.ServerError("userRepoIDs", err) + return + } + + switch filterMode { + case models.FilterModeAll: + opts.RepoIDs = userRepoIDs + case models.FilterModeAssign: + opts.AssigneeID = ctx.User.ID + case models.FilterModeCreate: + opts.PosterID = ctx.User.ID + case models.FilterModeMention: + opts.MentionedID = ctx.User.ID + case models.FilterModeReviewRequested: + opts.ReviewRequestedID = ctx.User.ID + } + + if ctxUser.IsOrganization() { + opts.RepoIDs = userRepoIDs + } + + // keyword holds the search term entered into the search field. + keyword := strings.Trim(ctx.Query("q"), " ") + ctx.Data["Keyword"] = keyword + + // Execute keyword search for issues. + // USING NON-FINAL STATE OF opts FOR A QUERY. + issueIDsFromSearch, err := issueIDsFromSearch(ctxUser, keyword, opts) + if err != nil { + ctx.ServerError("issueIDsFromSearch", err) + return + } + + // Ensure no issues are returned if a keyword was provided that didn't match any issues. + var forceEmpty bool + + if len(issueIDsFromSearch) > 0 { + opts.IssueIDs = issueIDsFromSearch + } else if len(keyword) > 0 { + forceEmpty = true + } + + // Educated guess: Do or don't show closed issues. + isShowClosed := ctx.Query("state") == "closed" + opts.IsClosed = util.OptionalBoolOf(isShowClosed) + + // Filter repos and count issues in them. Count will be used later. + // USING NON-FINAL STATE OF opts FOR A QUERY. + var issueCountByRepo map[int64]int64 + if !forceEmpty { + issueCountByRepo, err = models.CountIssuesByRepo(opts) + if err != nil { + ctx.ServerError("CountIssuesByRepo", err) + return + } + } + + // Make sure page number is at least 1. Will be posted to ctx.Data. + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + opts.Page = page + opts.PageSize = setting.UI.IssuePagingNum + + // Get IDs for labels (a filter option for issues/pulls). + // Required for IssuesOptions. + var labelIDs []int64 + selectedLabels := ctx.Query("labels") + if len(selectedLabels) > 0 && selectedLabels != "0" { + labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) + if err != nil { + ctx.ServerError("StringsToInt64s", err) + return + } + } + opts.LabelIDs = labelIDs + + // Parse ctx.Query("repos") and remember matched repo IDs for later. + // Gets set when clicking filters on the issues overview page. + repoIDs := getRepoIDs(ctx.Query("repos")) + if len(repoIDs) > 0 { + opts.RepoIDs = repoIDs + } + + // ------------------------------ + // Get issues as defined by opts. + // ------------------------------ + + // Slice of Issues that will be displayed on the overview page + // USING FINAL STATE OF opts FOR A QUERY. + var issues []*models.Issue + if !forceEmpty { + issues, err = models.Issues(opts) + if err != nil { + ctx.ServerError("Issues", err) + return + } + } else { + issues = []*models.Issue{} + } + + // ---------------------------------- + // Add repository pointers to Issues. + // ---------------------------------- + + // showReposMap maps repository IDs to their Repository pointers. + showReposMap, err := repoIDMap(ctxUser, issueCountByRepo, unitType) + if err != nil { + if models.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByID", err) + return + } + ctx.ServerError("repoIDMap", err) + return + } + + // a RepositoryList + showRepos := models.RepositoryListOfMap(showReposMap) + sort.Sort(showRepos) + if err = showRepos.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + // maps pull request IDs to their CommitStatus. Will be posted to ctx.Data. + for _, issue := range issues { + issue.Repo = showReposMap[issue.RepoID] + } + + commitStatus, err := pull_service.GetIssuesLastCommitStatus(issues) + if err != nil { + ctx.ServerError("GetIssuesLastCommitStatus", err) + return + } + + // ------------------------------- + // Fill stats to post to ctx.Data. + // ------------------------------- + + userIssueStatsOpts := models.UserIssueStatsOptions{ + UserID: ctx.User.ID, + UserRepoIDs: userRepoIDs, + FilterMode: filterMode, + IsPull: isPullList, + IsClosed: isShowClosed, + IsArchived: util.OptionalBoolFalse, + LabelIDs: opts.LabelIDs, + } + if len(repoIDs) > 0 { + userIssueStatsOpts.UserRepoIDs = repoIDs + } + if ctxUser.IsOrganization() { + userIssueStatsOpts.RepoIDs = userRepoIDs + } + userIssueStats, err := models.GetUserIssueStats(userIssueStatsOpts) + if err != nil { + ctx.ServerError("GetUserIssueStats User", err) + return + } + + var shownIssueStats *models.IssueStats + if !forceEmpty { + statsOpts := models.UserIssueStatsOptions{ + UserID: ctx.User.ID, + UserRepoIDs: userRepoIDs, + FilterMode: filterMode, + IsPull: isPullList, + IsClosed: isShowClosed, + IssueIDs: issueIDsFromSearch, + IsArchived: util.OptionalBoolFalse, + LabelIDs: opts.LabelIDs, + } + if len(repoIDs) > 0 { + statsOpts.RepoIDs = repoIDs + } else if ctxUser.IsOrganization() { + statsOpts.RepoIDs = userRepoIDs + } + shownIssueStats, err = models.GetUserIssueStats(statsOpts) + if err != nil { + ctx.ServerError("GetUserIssueStats Shown", err) + return + } + } else { + shownIssueStats = &models.IssueStats{} + } + + var allIssueStats *models.IssueStats + if !forceEmpty { + allIssueStatsOpts := models.UserIssueStatsOptions{ + UserID: ctx.User.ID, + UserRepoIDs: userRepoIDs, + FilterMode: filterMode, + IsPull: isPullList, + IsClosed: isShowClosed, + IssueIDs: issueIDsFromSearch, + IsArchived: util.OptionalBoolFalse, + LabelIDs: opts.LabelIDs, + } + if ctxUser.IsOrganization() { + allIssueStatsOpts.RepoIDs = userRepoIDs + } + allIssueStats, err = models.GetUserIssueStats(allIssueStatsOpts) + if err != nil { + ctx.ServerError("GetUserIssueStats All", err) + return + } + } else { + allIssueStats = &models.IssueStats{} + } + + // Will be posted to ctx.Data. + var shownIssues int + if !isShowClosed { + shownIssues = int(shownIssueStats.OpenCount) + ctx.Data["TotalIssueCount"] = int(allIssueStats.OpenCount) + } else { + shownIssues = int(shownIssueStats.ClosedCount) + ctx.Data["TotalIssueCount"] = int(allIssueStats.ClosedCount) + } + + ctx.Data["IsShowClosed"] = isShowClosed + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = + issue_service.GetRefEndNamesAndURLs(issues, ctx.Query("RepoLink")) + + ctx.Data["Issues"] = issues + + approvalCounts, err := models.IssueList(issues).GetApprovalCounts() + if err != nil { + ctx.ServerError("ApprovalCounts", err) + return + } + ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { + counts, ok := approvalCounts[issueID] + if !ok || len(counts) == 0 { + return 0 + } + reviewTyp := models.ReviewTypeApprove + if typ == "reject" { + reviewTyp = models.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = models.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + ctx.Data["CommitStatus"] = commitStatus + ctx.Data["Repos"] = showRepos + ctx.Data["Counts"] = issueCountByRepo + ctx.Data["IssueStats"] = userIssueStats + ctx.Data["ShownIssueStats"] = shownIssueStats + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["RepoIDs"] = repoIDs + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["SelectLabels"] = selectedLabels + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + // Convert []int64 to string + json := jsoniter.ConfigCompatibleWithStandardLibrary + reposParam, _ := json.Marshal(repoIDs) + + ctx.Data["ReposParam"] = string(reposParam) + + pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "type", "ViewType") + pager.AddParam(ctx, "repos", "ReposParam") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + pager.AddParam(ctx, "labels", "SelectLabels") + pager.AddParam(ctx, "milestone", "MilestoneID") + pager.AddParam(ctx, "assignee", "AssigneeID") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplIssues) +} + +func getRepoIDs(reposQuery string) []int64 { + if len(reposQuery) == 0 || reposQuery == "[]" { + return []int64{} + } + if !issueReposQueryPattern.MatchString(reposQuery) { + log.Warn("issueReposQueryPattern does not match query") + return []int64{} + } + + var repoIDs []int64 + // remove "[" and "]" from string + reposQuery = reposQuery[1 : len(reposQuery)-1] + //for each ID (delimiter ",") add to int to repoIDs + for _, rID := range strings.Split(reposQuery, ",") { + // Ensure nonempty string entries + if rID != "" && rID != "0" { + rIDint64, err := strconv.ParseInt(rID, 10, 64) + if err == nil { + repoIDs = append(repoIDs, rIDint64) + } + } + } + + return repoIDs +} + +func getActiveUserRepoIDs(ctxUser *models.User, team *models.Team, unitType models.UnitType) ([]int64, error) { + var userRepoIDs []int64 + var err error + + if ctxUser.IsOrganization() { + userRepoIDs, err = getActiveTeamOrOrgRepoIds(ctxUser, team, unitType) + if err != nil { + return nil, fmt.Errorf("orgRepoIds: %v", err) + } + } else { + userRepoIDs, err = ctxUser.GetActiveAccessRepoIDs(unitType) + if err != nil { + return nil, fmt.Errorf("ctxUser.GetAccessRepoIDs: %v", err) + } + } + + if len(userRepoIDs) == 0 { + userRepoIDs = []int64{-1} + } + + return userRepoIDs, nil +} + +// getActiveTeamOrOrgRepoIds gets RepoIDs for ctxUser as Organization. +// Should be called if and only if ctxUser.IsOrganization == true. +func getActiveTeamOrOrgRepoIds(ctxUser *models.User, team *models.Team, unitType models.UnitType) ([]int64, error) { + var orgRepoIDs []int64 + var err error + var env models.AccessibleReposEnvironment + + if team != nil { + env = ctxUser.AccessibleTeamReposEnv(team) + } else { + env, err = ctxUser.AccessibleReposEnv(ctxUser.ID) + if err != nil { + return nil, fmt.Errorf("AccessibleReposEnv: %v", err) + } + } + orgRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos) + if err != nil { + return nil, fmt.Errorf("env.RepoIDs: %v", err) + } + orgRepoIDs, err = models.FilterOutRepoIdsWithoutUnitAccess(ctxUser, orgRepoIDs, unitType) + if err != nil { + return nil, fmt.Errorf("FilterOutRepoIdsWithoutUnitAccess: %v", err) + } + + return orgRepoIDs, nil +} + +func issueIDsFromSearch(ctxUser *models.User, keyword string, opts *models.IssuesOptions) ([]int64, error) { + if len(keyword) == 0 { + return []int64{}, nil + } + + searchRepoIDs, err := models.GetRepoIDsForIssuesOptions(opts, ctxUser) + if err != nil { + return nil, fmt.Errorf("GetRepoIDsForIssuesOptions: %v", err) + } + issueIDsFromSearch, err := issue_indexer.SearchIssuesByKeyword(searchRepoIDs, keyword) + if err != nil { + return nil, fmt.Errorf("SearchIssuesByKeyword: %v", err) + } + + return issueIDsFromSearch, nil +} + +func repoIDMap(ctxUser *models.User, issueCountByRepo map[int64]int64, unitType models.UnitType) (map[int64]*models.Repository, error) { + repoByID := make(map[int64]*models.Repository, len(issueCountByRepo)) + for id := range issueCountByRepo { + if id <= 0 { + continue + } + if _, ok := repoByID[id]; !ok { + repo, err := models.GetRepositoryByID(id) + if models.IsErrRepoNotExist(err) { + return nil, err + } else if err != nil { + return nil, fmt.Errorf("GetRepositoryByID: [%d]%v", id, err) + } + repoByID[id] = repo + } + repo := repoByID[id] + + // Check if user has access to given repository. + perm, err := models.GetUserRepoPermission(repo, ctxUser) + if err != nil { + return nil, fmt.Errorf("GetUserRepoPermission: [%d]%v", id, err) + } + if !perm.CanRead(unitType) { + log.Debug("User created Issues in Repository which they no longer have access to: [%d]", id) + } + } + return repoByID, nil +} + +// ShowSSHKeys output all the ssh keys of user by uid +func ShowSSHKeys(ctx *context.Context, uid int64) { + keys, err := models.ListPublicKeys(uid, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + + var buf bytes.Buffer + for i := range keys { + buf.WriteString(keys[i].OmitEmail()) + buf.WriteString("\n") + } + ctx.PlainText(200, buf.Bytes()) +} + +// ShowGPGKeys output all the public GPG keys of user by uid +func ShowGPGKeys(ctx *context.Context, uid int64) { + keys, err := models.ListGPGKeys(uid, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + entities := make([]*openpgp.Entity, 0) + failedEntitiesID := make([]string, 0) + for _, k := range keys { + e, err := models.GPGKeyToEntity(k) + if err != nil { + if models.IsErrGPGKeyImportNotExist(err) { + failedEntitiesID = append(failedEntitiesID, k.KeyID) + continue //Skip previous import without backup of imported armored key + } + ctx.ServerError("ShowGPGKeys", err) + return + } + entities = append(entities, e) + } + var buf bytes.Buffer + + headers := make(map[string]string) + if len(failedEntitiesID) > 0 { //If some key need re-import to be exported + headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", ")) + } + writer, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", headers) + for _, e := range entities { + err = e.Serialize(writer) //TODO find why key are exported with a different cipherTypeByte as original (should not be blocking but strange) + if err != nil { + ctx.ServerError("ShowGPGKeys", err) + return + } + } + writer.Close() + ctx.PlainText(200, buf.Bytes()) +} + +// Email2User show user page via email +func Email2User(ctx *context.Context) { + u, err := models.GetUserByEmail(ctx.Query("email")) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByEmail", err) + } else { + ctx.ServerError("GetUserByEmail", err) + } + return + } + ctx.Redirect(setting.AppSubURL + "/user/" + u.Name) +} diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go new file mode 100644 index 0000000000..b0109c354f --- /dev/null +++ b/routers/web/user/home_test.go @@ -0,0 +1,118 @@ +// 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 user + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestArchivedIssues(t *testing.T) { + // Arrange + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "issues") + test.LoadUser(t, ctx, 30) + ctx.Req.Form.Set("state", "open") + + // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived. + repos, _, _ := models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctx.User}) + assert.Len(t, repos, 2) + IsArchived := make(map[int64]bool) + NumIssues := make(map[int64]int) + for _, repo := range repos { + IsArchived[repo.ID] = repo.IsArchived + NumIssues[repo.ID] = repo.NumIssues + } + assert.False(t, IsArchived[50]) + assert.EqualValues(t, 1, NumIssues[50]) + assert.True(t, IsArchived[51]) + assert.EqualValues(t, 1, NumIssues[51]) + + // Act + Issues(ctx) + + // Assert: One Issue (ID 30) from one Repo (ID 50) is retrieved, while nothing from archived Repo 51 is retrieved + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.EqualValues(t, map[int64]int64{50: 1}, ctx.Data["Counts"]) + assert.Len(t, ctx.Data["Issues"], 1) + assert.Len(t, ctx.Data["Repos"], 1) +} + +func TestIssues(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "issues") + test.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "closed") + Issues(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.EqualValues(t, map[int64]int64{1: 1, 2: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.Len(t, ctx.Data["Issues"], 1) + assert.Len(t, ctx.Data["Repos"], 2) +} + +func TestPulls(t *testing.T) { + setting.UI.IssuePagingNum = 20 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "pulls") + test.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "open") + Pulls(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.Len(t, ctx.Data["Issues"], 3) +} + +func TestMilestones(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "milestones") + test.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2 +} + +func TestMilestonesForSpecificRepo(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "milestones") + test.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.SetParams("repo", "1") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2 +} diff --git a/routers/web/user/main_test.go b/routers/web/user/main_test.go new file mode 100644 index 0000000000..be17dd1f31 --- /dev/null +++ b/routers/web/user/main_test.go @@ -0,0 +1,16 @@ +// 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 user + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..", "..")) +} diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go new file mode 100644 index 0000000000..523e945db9 --- /dev/null +++ b/routers/web/user/notification.go @@ -0,0 +1,192 @@ +// 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 user + +import ( + "errors" + "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/setting" +) + +const ( + tplNotification base.TplName = "user/notification/notification" + tplNotificationDiv base.TplName = "user/notification/notification_div" +) + +// GetNotificationCount is the middleware that sets the notification count in the context +func GetNotificationCount(c *context.Context) { + if strings.HasPrefix(c.Req.URL.Path, "/api") { + return + } + + if !c.IsSigned { + return + } + + c.Data["NotificationUnreadCount"] = func() int64 { + count, err := models.GetNotificationCount(c.User, models.NotificationStatusUnread) + if err != nil { + c.ServerError("GetNotificationCount", err) + return -1 + } + + return count + } +} + +// Notifications is the notifications page +func Notifications(c *context.Context) { + getNotifications(c) + if c.Written() { + return + } + if c.QueryBool("div-only") { + c.HTML(http.StatusOK, tplNotificationDiv) + return + } + c.HTML(http.StatusOK, tplNotification) +} + +func getNotifications(c *context.Context) { + var ( + keyword = strings.Trim(c.Query("q"), " ") + status models.NotificationStatus + page = c.QueryInt("page") + perPage = c.QueryInt("perPage") + ) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + + switch keyword { + case "read": + status = models.NotificationStatusRead + default: + status = models.NotificationStatusUnread + } + + total, err := models.GetNotificationCount(c.User, status) + if err != nil { + c.ServerError("ErrGetNotificationCount", err) + return + } + + // redirect to last page if request page is more than total pages + pager := context.NewPagination(int(total), perPage, page, 5) + if pager.Paginater.Current() < page { + c.Redirect(fmt.Sprintf("/notifications?q=%s&page=%d", c.Query("q"), pager.Paginater.Current())) + return + } + + statuses := []models.NotificationStatus{status, models.NotificationStatusPinned} + notifications, err := models.NotificationsForUser(c.User, statuses, page, perPage) + if err != nil { + c.ServerError("ErrNotificationsForUser", err) + return + } + + failCount := 0 + + repos, failures, err := notifications.LoadRepos() + if err != nil { + c.ServerError("LoadRepos", err) + return + } + notifications = notifications.Without(failures) + if err := repos.LoadAttributes(); err != nil { + c.ServerError("LoadAttributes", err) + return + } + failCount += len(failures) + + failures, err = notifications.LoadIssues() + if err != nil { + c.ServerError("LoadIssues", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + + failures, err = notifications.LoadComments() + if err != nil { + c.ServerError("LoadComments", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + + if failCount > 0 { + c.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount)) + } + + c.Data["Title"] = c.Tr("notifications") + c.Data["Keyword"] = keyword + c.Data["Status"] = status + c.Data["Notifications"] = notifications + + pager.SetDefaultParams(c) + c.Data["Page"] = pager +} + +// NotificationStatusPost is a route for changing the status of a notification +func NotificationStatusPost(c *context.Context) { + var ( + notificationID, _ = strconv.ParseInt(c.Req.PostFormValue("notification_id"), 10, 64) + statusStr = c.Req.PostFormValue("status") + status models.NotificationStatus + ) + + switch statusStr { + case "read": + status = models.NotificationStatusRead + case "unread": + status = models.NotificationStatusUnread + case "pinned": + status = models.NotificationStatusPinned + default: + c.ServerError("InvalidNotificationStatus", errors.New("Invalid notification status")) + return + } + + if err := models.SetNotificationStatus(notificationID, c.User, status); err != nil { + c.ServerError("SetNotificationStatus", err) + return + } + + if !c.QueryBool("noredirect") { + url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, c.Query("page")) + c.Redirect(url, http.StatusSeeOther) + } + + getNotifications(c) + if c.Written() { + return + } + c.Data["Link"] = setting.AppURL + "notifications" + + c.HTML(http.StatusOK, tplNotificationDiv) +} + +// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read +func NotificationPurgePost(c *context.Context) { + err := models.UpdateNotificationStatuses(c.User, models.NotificationStatusUnread, models.NotificationStatusRead) + if err != nil { + c.ServerError("ErrUpdateNotificationStatuses", err) + return + } + + url := fmt.Sprintf("%s/notifications", setting.AppSubURL) + c.Redirect(url, http.StatusSeeOther) +} diff --git a/routers/web/user/oauth.go b/routers/web/user/oauth.go new file mode 100644 index 0000000000..3ef5a56c01 --- /dev/null +++ b/routers/web/user/oauth.go @@ -0,0 +1,646 @@ +// 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 user + +import ( + "encoding/base64" + "fmt" + "html" + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/sso" + "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/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "gitea.com/go-chi/binding" + "github.com/dgrijalva/jwt-go" +) + +const ( + tplGrantAccess base.TplName = "user/auth/grant" + tplGrantError base.TplName = "user/auth/grant_error" +) + +// TODO move error and responses to SDK or models + +// AuthorizeErrorCode represents an error code specified in RFC 6749 +type AuthorizeErrorCode string + +const ( + // ErrorCodeInvalidRequest represents the according error in RFC 6749 + ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request" + // ErrorCodeUnauthorizedClient represents the according error in RFC 6749 + ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client" + // ErrorCodeAccessDenied represents the according error in RFC 6749 + ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied" + // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749 + ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type" + // ErrorCodeInvalidScope represents the according error in RFC 6749 + ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope" + // ErrorCodeServerError represents the according error in RFC 6749 + ErrorCodeServerError AuthorizeErrorCode = "server_error" + // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749 + ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable" +) + +// AuthorizeError represents an error type specified in RFC 6749 +type AuthorizeError struct { + ErrorCode AuthorizeErrorCode `json:"error" form:"error"` + ErrorDescription string + State string +} + +// Error returns the error message +func (err AuthorizeError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// AccessTokenErrorCode represents an error code specified in RFC 6749 +type AccessTokenErrorCode string + +const ( + // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request" + // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidClient = "invalid_client" + // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidGrant = "invalid_grant" + // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client" + // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type" + // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidScope = "invalid_scope" +) + +// AccessTokenError represents an error response specified in RFC 6749 +type AccessTokenError struct { + ErrorCode AccessTokenErrorCode `json:"error" form:"error"` + ErrorDescription string `json:"error_description"` +} + +// Error returns the error message +func (err AccessTokenError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// BearerTokenErrorCode represents an error code specified in RFC 6750 +type BearerTokenErrorCode string + +const ( + // BearerTokenErrorCodeInvalidRequest represents an error code specified in RFC 6750 + BearerTokenErrorCodeInvalidRequest BearerTokenErrorCode = "invalid_request" + // BearerTokenErrorCodeInvalidToken represents an error code specified in RFC 6750 + BearerTokenErrorCodeInvalidToken BearerTokenErrorCode = "invalid_token" + // BearerTokenErrorCodeInsufficientScope represents an error code specified in RFC 6750 + BearerTokenErrorCodeInsufficientScope BearerTokenErrorCode = "insufficient_scope" +) + +// BearerTokenError represents an error response specified in RFC 6750 +type BearerTokenError struct { + ErrorCode BearerTokenErrorCode `json:"error" form:"error"` + ErrorDescription string `json:"error_description"` +} + +// TokenType specifies the kind of token +type TokenType string + +const ( + // TokenTypeBearer represents a token type specified in RFC 6749 + TokenTypeBearer TokenType = "bearer" + // TokenTypeMAC represents a token type specified in RFC 6749 + TokenTypeMAC = "mac" +) + +// AccessTokenResponse represents a successful access token response +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType TokenType `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` +} + +func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*AccessTokenResponse, *AccessTokenError) { + if setting.OAuth2.InvalidateRefreshTokens { + if err := grant.IncreaseCounter(); err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "cannot increase the grant counter", + } + } + } + // generate access token to access the API + expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime) + accessToken := &models.OAuth2Token{ + GrantID: grant.ID, + Type: models.TypeAccessToken, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationDate.AsTime().Unix(), + }, + } + signedAccessToken, err := accessToken.SignToken() + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate refresh token to request an access token after it expired later + refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime().Unix() + refreshToken := &models.OAuth2Token{ + GrantID: grant.ID, + Counter: grant.Counter, + Type: models.TypeRefreshToken, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: refreshExpirationDate, + }, + } + signedRefreshToken, err := refreshToken.SignToken() + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate OpenID Connect id_token + signedIDToken := "" + if grant.ScopeContains("openid") { + app, err := models.GetOAuth2ApplicationByID(grant.ApplicationID) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find application", + } + } + idToken := &models.OIDCToken{ + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationDate.AsTime().Unix(), + Issuer: setting.AppURL, + Audience: app.ClientID, + Subject: fmt.Sprint(grant.UserID), + }, + Nonce: grant.Nonce, + } + signedIDToken, err = idToken.SignToken(clientSecret) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + } + + return &AccessTokenResponse{ + AccessToken: signedAccessToken, + TokenType: TokenTypeBearer, + ExpiresIn: setting.OAuth2.AccessTokenExpirationTime, + RefreshToken: signedRefreshToken, + IDToken: signedIDToken, + }, nil +} + +type userInfoResponse struct { + Sub string `json:"sub"` + Name string `json:"name"` + Username string `json:"preferred_username"` + Email string `json:"email"` + Picture string `json:"picture"` +} + +// InfoOAuth manages request for userinfo endpoint +func InfoOAuth(ctx *context.Context) { + header := ctx.Req.Header.Get("Authorization") + auths := strings.Fields(header) + if len(auths) != 2 || auths[0] != "Bearer" { + ctx.HandleText(http.StatusUnauthorized, "no valid auth token authorization") + return + } + uid := sso.CheckOAuthAccessToken(auths[1]) + if uid == 0 { + handleBearerTokenError(ctx, BearerTokenError{ + ErrorCode: BearerTokenErrorCodeInvalidToken, + ErrorDescription: "Access token not assigned to any user", + }) + return + } + authUser, err := models.GetUserByID(uid) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + response := &userInfoResponse{ + Sub: fmt.Sprint(authUser.ID), + Name: authUser.FullName, + Username: authUser.Name, + Email: authUser.Email, + Picture: authUser.AvatarLink(), + } + ctx.JSON(http.StatusOK, response) +} + +// AuthorizeOAuth manages authorize requests +func AuthorizeOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AuthorizationForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) + if err != nil { + if models.IsErrOauthClientIDInvalid(err) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnauthorizedClient, + ErrorDescription: "Client ID not registered", + State: form.State, + }, "") + return + } + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + if err := app.LoadUser(); err != nil { + ctx.ServerError("LoadUser", err) + return + } + + if !app.ContainsRedirectURI(form.RedirectURI) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "Unregistered Redirect URI", + State: form.State, + }, "") + return + } + + if form.ResponseType != "code" { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnsupportedResponseType, + ErrorDescription: "Only code response type is supported.", + State: form.State, + }, form.RedirectURI) + return + } + + // pkce support + switch form.CodeChallengeMethod { + case "S256": + case "plain": + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge", + State: form.State, + }, form.RedirectURI) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + case "": + break + default: + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "unsupported code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + + grant, err := app.GetGrantByUserID(ctx.User.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + + // Redirect if user already granted access + if grant != nil { + code, err := grant.GenerateNewAuthorizationCode(form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + // Update nonce to reflect the new session + if len(form.Nonce) > 0 { + err := grant.SetNonce(form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + ctx.Redirect(redirect.String(), 302) + return + } + + // show authorize page to grant access + ctx.Data["Application"] = app + ctx.Data["RedirectURI"] = form.RedirectURI + ctx.Data["State"] = form.State + ctx.Data["Scope"] = form.Scope + ctx.Data["Nonce"] = form.Nonce + ctx.Data["ApplicationUserLink"] = "<a href=\"" + html.EscapeString(setting.AppURL) + html.EscapeString(url.PathEscape(app.User.LowerName)) + "\">@" + html.EscapeString(app.User.Name) + "</a>" + ctx.Data["ApplicationRedirectDomainHTML"] = "<strong>" + html.EscapeString(form.RedirectURI) + "</strong>" + // TODO document SESSION <=> FORM + err = ctx.Session.Set("client_id", app.ClientID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("redirect_uri", form.RedirectURI) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("state", form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + ctx.HTML(http.StatusOK, tplGrantAccess) +} + +// GrantApplicationOAuth manages the post request submitted when a user grants access to an application +func GrantApplicationOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.GrantApplicationForm) + if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State || + ctx.Session.Get("redirect_uri") != form.RedirectURI { + ctx.Error(http.StatusBadRequest) + return + } + app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + grant, err := app.CreateGrant(ctx.User.ID, form.Scope) + if err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "cannot create grant for user", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + if len(form.Nonce) > 0 { + err := grant.SetNonce(form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + + var codeChallenge, codeChallengeMethod string + codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string) + codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string) + + code, err := grant.GenerateNewAuthorizationCode(form.RedirectURI, codeChallenge, codeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + ctx.Redirect(redirect.String(), 302) +} + +// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities +func OIDCWellKnown(ctx *context.Context) { + t := ctx.Render.TemplateLookup("user/auth/oidc_wellknown") + ctx.Resp.Header().Set("Content-Type", "application/json") + if err := t.Execute(ctx.Resp, ctx.Data); err != nil { + log.Error("%v", err) + ctx.Error(http.StatusInternalServerError) + } +} + +// AccessTokenOAuth manages all access token requests by the client +func AccessTokenOAuth(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AccessTokenForm) + if form.ClientID == "" { + authHeader := ctx.Req.Header.Get("Authorization") + authContent := strings.SplitN(authHeader, " ", 2) + if len(authContent) == 2 && authContent[0] == "Basic" { + payload, err := base64.StdEncoding.DecodeString(authContent[1]) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot parse basic auth header", + }) + return + } + pair := strings.SplitN(string(payload), ":", 2) + if len(pair) != 2 { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot parse basic auth header", + }) + return + } + form.ClientID = pair[0] + form.ClientSecret = pair[1] + } + } + switch form.GrantType { + case "refresh_token": + handleRefreshToken(ctx, form) + return + case "authorization_code": + handleAuthorizationCode(ctx, form) + return + default: + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnsupportedGrantType, + ErrorDescription: "Only refresh_token or authorization_code grant type is supported", + }) + } +} + +func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm) { + token, err := models.ParseOAuth2Token(form.RefreshToken) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // get grant before increasing counter + grant, err := models.GetOAuth2GrantByID(token.GrantID) + if err != nil || grant == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "grant does not exist", + }) + return + } + + // check if token got already used + if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "token was already used", + }) + log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) + return + } + accessToken, tokenErr := newAccessTokenResponse(grant, form.ClientSecret) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + ctx.JSON(http.StatusOK, accessToken) +} + +func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm) { + app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID), + }) + return + } + if !app.ValidateClientSecret([]byte(form.ClientSecret)) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + authorizationCode, err := models.GetOAuth2AuthorizationByCode(form.Code) + if err != nil || authorizationCode == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // check if code verifier authorizes the client, PKCE support + if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // check if granted for this application + if authorizationCode.Grant.ApplicationID != app.ID { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "invalid grant", + }) + return + } + // remove token from database to deny duplicate usage + if err := authorizationCode.Invalidate(); err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot proceed your request", + }) + } + resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant, form.ClientSecret) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + // send successful response + ctx.JSON(http.StatusOK, resp) +} + +func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) { + ctx.JSON(http.StatusBadRequest, acErr) +} + +func handleServerError(ctx *context.Context, state string, redirectURI string) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "A server error occurred", + State: state, + }, redirectURI) +} + +func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) { + if redirectURI == "" { + log.Warn("Authorization failed: %v", authErr.ErrorDescription) + ctx.Data["Error"] = authErr + ctx.HTML(400, tplGrantError) + return + } + redirect, err := url.Parse(redirectURI) + if err != nil { + ctx.ServerError("url.Parse", err) + return + } + q := redirect.Query() + q.Set("error", string(authErr.ErrorCode)) + q.Set("error_description", authErr.ErrorDescription) + q.Set("state", authErr.State) + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String(), 302) +} + +func handleBearerTokenError(ctx *context.Context, beErr BearerTokenError) { + ctx.Resp.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"\", error=\"%s\", error_description=\"%s\"", beErr.ErrorCode, beErr.ErrorDescription)) + switch beErr.ErrorCode { + case BearerTokenErrorCodeInvalidRequest: + ctx.JSON(http.StatusBadRequest, beErr) + case BearerTokenErrorCodeInvalidToken: + ctx.JSON(http.StatusUnauthorized, beErr) + case BearerTokenErrorCodeInsufficientScope: + ctx.JSON(http.StatusForbidden, beErr) + default: + log.Error("Invalid BearerTokenErrorCode: %v", beErr.ErrorCode) + ctx.ServerError("Unhandled BearerTokenError", fmt.Errorf("BearerTokenError: error=\"%v\", error_description=\"%v\"", beErr.ErrorCode, beErr.ErrorDescription)) + } +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go new file mode 100644 index 0000000000..e66820e131 --- /dev/null +++ b/routers/web/user/profile.go @@ -0,0 +1,329 @@ +// Copyright 2015 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 user + +import ( + "fmt" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/org" +) + +// GetUserByName get user by name +func GetUserByName(ctx *context.Context, name string) *models.User { + user, err := models.GetUserByName(name) + if err != nil { + if models.IsErrUserNotExist(err) { + if redirectUserID, err := models.LookupUserRedirect(name); err == nil { + context.RedirectToUser(ctx, name, redirectUserID) + } else { + ctx.NotFound("GetUserByName", err) + } + } else { + ctx.ServerError("GetUserByName", err) + } + return nil + } + return user +} + +// GetUserByParams returns user whose name is presented in URL paramenter. +func GetUserByParams(ctx *context.Context) *models.User { + return GetUserByName(ctx, ctx.Params(":username")) +} + +// Profile render user's profile page +func Profile(ctx *context.Context) { + uname := ctx.Params(":username") + + // Special handle for FireFox requests favicon.ico. + if uname == "favicon.ico" { + ctx.ServeFile(path.Join(setting.StaticRootPath, "public/img/favicon.png")) + return + } + + if strings.HasSuffix(uname, ".png") { + ctx.Error(http.StatusNotFound) + return + } + + isShowKeys := false + if strings.HasSuffix(uname, ".keys") { + isShowKeys = true + uname = strings.TrimSuffix(uname, ".keys") + } + + isShowGPG := false + if strings.HasSuffix(uname, ".gpg") { + isShowGPG = true + uname = strings.TrimSuffix(uname, ".gpg") + } + + ctxUser := GetUserByName(ctx, uname) + if ctx.Written() { + return + } + + // Show SSH keys. + if isShowKeys { + ShowSSHKeys(ctx, ctxUser.ID) + return + } + + // Show GPG keys. + if isShowGPG { + ShowGPGKeys(ctx, ctxUser.ID) + return + } + + if ctxUser.IsOrganization() { + org.Home(ctx) + return + } + + // Show OpenID URIs + openIDs, err := models.GetUserOpenIDs(ctxUser.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + + ctx.Data["Title"] = ctxUser.DisplayName() + ctx.Data["PageIsUserProfile"] = true + ctx.Data["Owner"] = ctxUser + ctx.Data["OpenIDs"] = openIDs + + if setting.Service.EnableUserHeatmap { + data, err := models.GetUserHeatmapDataByUser(ctxUser, ctx.User) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUser", err) + return + } + ctx.Data["HeatmapData"] = data + } + + if len(ctxUser.Description) != 0 { + content, err := markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: map[string]string{"mode": "document"}, + }, ctxUser.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + ctx.Data["RenderedDescription"] = content + } + + showPrivate := ctx.IsSigned && (ctx.User.IsAdmin || ctx.User.ID == ctxUser.ID) + + orgs, err := models.GetOrgsByUserID(ctxUser.ID, showPrivate) + if err != nil { + ctx.ServerError("GetOrgsByUserIDDesc", err) + return + } + + ctx.Data["Orgs"] = orgs + ctx.Data["HasOrgsVisible"] = models.HasOrgsVisible(orgs, ctx.User) + + tab := ctx.Query("tab") + ctx.Data["TabName"] = tab + + page := ctx.QueryInt("page") + if page <= 0 { + page = 1 + } + + topicOnly := ctx.QueryBool("topic") + + var ( + repos []*models.Repository + count int64 + total int + orderBy models.SearchOrderBy + ) + + ctx.Data["SortType"] = ctx.Query("sort") + switch ctx.Query("sort") { + case "newest": + orderBy = models.SearchOrderByNewest + case "oldest": + orderBy = models.SearchOrderByOldest + case "recentupdate": + orderBy = models.SearchOrderByRecentUpdated + case "leastupdate": + orderBy = models.SearchOrderByLeastUpdated + case "reversealphabetically": + orderBy = models.SearchOrderByAlphabeticallyReverse + case "alphabetically": + orderBy = models.SearchOrderByAlphabetically + case "moststars": + orderBy = models.SearchOrderByStarsReverse + case "feweststars": + orderBy = models.SearchOrderByStars + case "mostforks": + orderBy = models.SearchOrderByForksReverse + case "fewestforks": + orderBy = models.SearchOrderByForks + default: + ctx.Data["SortType"] = "recentupdate" + orderBy = models.SearchOrderByRecentUpdated + } + + keyword := strings.Trim(ctx.Query("q"), " ") + ctx.Data["Keyword"] = keyword + switch tab { + case "followers": + items, err := ctxUser.GetFollowers(models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetFollowers", err) + return + } + ctx.Data["Cards"] = items + + total = ctxUser.NumFollowers + case "following": + items, err := ctxUser.GetFollowing(models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetFollowing", err) + return + } + ctx.Data["Cards"] = items + + total = ctxUser.NumFollowing + case "activity": + retrieveFeeds(ctx, models.GetFeedsOptions{RequestedUser: ctxUser, + Actor: ctx.User, + IncludePrivate: showPrivate, + OnlyPerformedBy: true, + IncludeDeleted: false, + Date: ctx.Query("date"), + }) + if ctx.Written() { + return + } + case "stars": + ctx.Data["PageIsProfileStarList"] = true + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.User, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + StarredByID: ctxUser.ID, + Collaborate: util.OptionalBoolFalse, + TopicOnly: topicOnly, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + case "projects": + ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: models.ProjectTypeIndividual, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + case "watching": + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.User, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + WatchedByID: ctxUser.ID, + Collaborate: util.OptionalBoolFalse, + TopicOnly: topicOnly, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + default: + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.User, + Keyword: keyword, + OwnerID: ctxUser.ID, + OrderBy: orderBy, + Private: ctx.IsSigned, + Collaborate: util.OptionalBoolFalse, + TopicOnly: topicOnly, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + } + ctx.Data["Repos"] = repos + ctx.Data["Total"] = total + + pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.Data["ShowUserEmail"] = len(ctxUser.Email) > 0 && ctx.IsSigned && (!ctxUser.KeepEmailPrivate || ctxUser.ID == ctx.User.ID) + + ctx.HTML(http.StatusOK, tplProfile) +} + +// Action response for follow/unfollow user request +func Action(ctx *context.Context) { + u := GetUserByParams(ctx) + if ctx.Written() { + return + } + + var err error + switch ctx.Params(":action") { + case "follow": + err = models.FollowUser(ctx.User.ID, u.ID) + case "unfollow": + err = models.UnfollowUser(ctx.User.ID, u.ID) + } + + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + + ctx.RedirectToFirst(ctx.Query("redirect_to"), u.HomeLink()) +} diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go new file mode 100644 index 0000000000..48ab37d936 --- /dev/null +++ b/routers/web/user/setting/account.go @@ -0,0 +1,313 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 setting + +import ( + "errors" + "net/http" + "time" + + "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/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" +) + +const ( + tplSettingsAccount base.TplName = "user/settings/account" +) + +// Account renders change user's password, user's email and user suicide page +func Account(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.User.Email + + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) +} + +// AccountPost response for change user's password +func AccountPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ChangePasswordForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if ctx.HasError() { + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) + return + } + + if len(form.Password) < setting.MinPasswordLength { + ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) + } else if ctx.User.IsPasswordSet() && !ctx.User.ValidatePassword(form.OldPassword) { + ctx.Flash.Error(ctx.Tr("settings.password_incorrect")) + } else if form.Password != form.Retype { + ctx.Flash.Error(ctx.Tr("form.password_not_match")) + } else if !password.IsComplexEnough(form.Password) { + ctx.Flash.Error(password.BuildComplexityError(ctx)) + } else if pwned, err := password.IsPwned(ctx, form.Password); pwned || err != nil { + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Flash.Error(errMsg) + } else { + var err error + if err = ctx.User.SetPassword(form.Password); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := models.UpdateUserCols(ctx.User, "salt", "passwd_hash_algo", "passwd"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + log.Trace("User password updated: %s", ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// EmailPost response for change user's email +func EmailPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddEmailForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + // Make emailaddress primary. + if ctx.Query("_method") == "PRIMARY" { + if err := models.MakeEmailPrimary(&models.EmailAddress{ID: ctx.QueryInt64("id")}); err != nil { + ctx.ServerError("MakeEmailPrimary", err) + return + } + + log.Trace("Email made primary: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Send activation Email + if ctx.Query("_method") == "SENDACTIVATION" { + var address string + if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) { + log.Error("Send activation: activation still pending") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if ctx.Query("id") == "PRIMARY" { + if ctx.User.IsActive { + log.Error("Send activation: email not set for activation") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + mailer.SendActivateAccountMail(ctx.Locale, ctx.User) + address = ctx.User.Email + } else { + id := ctx.QueryInt64("id") + email, err := models.GetEmailAddressByID(ctx.User.ID, id) + if err != nil { + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.User.ID, id, err) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email == nil { + log.Error("Send activation: EmailAddress not found; user:%d, id: %d", ctx.User.ID, id) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email.IsActivated { + log.Error("Send activation: email not set for activation") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + mailer.SendActivateEmailMail(ctx.User, email) + address = email.Email + } + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()))) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Set Email Notification Preference + if ctx.Query("_method") == "NOTIFICATION" { + preference := ctx.Query("preference") + if !(preference == models.EmailNotificationsEnabled || + preference == models.EmailNotificationsOnMention || + preference == models.EmailNotificationsDisabled) { + log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.User.Name) + ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) + return + } + if err := ctx.User.SetEmailNotifications(preference); err != nil { + log.Error("Set Email Notifications failed: %v", err) + ctx.ServerError("SetEmailNotifications", err) + return + } + log.Trace("Email notifications preference made %s: %s", preference, ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if ctx.HasError() { + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) + return + } + + email := &models.EmailAddress{ + UID: ctx.User.ID, + Email: form.Email, + IsActivated: !setting.Service.RegisterEmailConfirm, + } + if err := models.AddEmailAddress(email); err != nil { + if models.IsErrEmailAlreadyUsed(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form) + return + } else if models.IsErrEmailInvalid(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form) + return + } + ctx.ServerError("AddEmailAddress", err) + return + } + + // Send confirmation email + if setting.Service.RegisterEmailConfirm { + mailer.SendActivateEmailMail(ctx.User, email) + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", email.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()))) + } else { + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + } + + log.Trace("Email address added: %s", email.Email) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// DeleteEmail response for delete user's email +func DeleteEmail(ctx *context.Context) { + if err := models.DeleteEmailAddress(&models.EmailAddress{ID: ctx.QueryInt64("id"), UID: ctx.User.ID}); err != nil { + ctx.ServerError("DeleteEmail", err) + return + } + log.Trace("Email address deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.email_deletion_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/account", + }) +} + +// DeleteAccount render user suicide page and response for delete user himself +func DeleteAccount(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if _, err := models.UserSignIn(ctx.User.Name, ctx.Query("password")); err != nil { + if models.IsErrUserNotExist(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil) + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + + if err := models.DeleteUser(ctx.User); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("form.still_own_repo")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("form.still_has_org")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + default: + ctx.ServerError("DeleteUser", err) + } + } else { + log.Trace("Account deleted: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubURL + "/") + } +} + +// UpdateUIThemePost is used to update users' specific theme +func UpdateUIThemePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateThemeForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if ctx.HasError() { + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if !form.IsThemeExists() { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if err := ctx.User.UpdateTheme(form.Theme); err != nil { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + log.Trace("Update user theme: %s", ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.theme_update_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +func loadAccountData(ctx *context.Context) { + emlist, err := models.GetEmailAddresses(ctx.User.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + type UserEmail struct { + models.EmailAddress + CanBePrimary bool + } + pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) + emails := make([]*UserEmail, len(emlist)) + for i, em := range emlist { + var email UserEmail + email.EmailAddress = *em + email.CanBePrimary = em.IsActivated + emails[i] = &email + } + ctx.Data["Emails"] = emails + ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications() + ctx.Data["ActivationsPending"] = pendingActivation + ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm + + if setting.Service.UserDeleteWithCommentsMaxTime != 0 { + ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String() + ctx.Data["UserDeleteWithComments"] = ctx.User.CreatedUnix.AsTime().Add(setting.Service.UserDeleteWithCommentsMaxTime).After(time.Now()) + } +} diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go new file mode 100644 index 0000000000..25b68da762 --- /dev/null +++ b/routers/web/user/setting/account_test.go @@ -0,0 +1,99 @@ +// 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 setting + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" +) + +func TestChangePassword(t *testing.T) { + oldPassword := "password" + setting.MinPasswordLength = 6 + var pcALL = []string{"lower", "upper", "digit", "spec"} + var pcLUN = []string{"lower", "upper", "digit"} + var pcLU = []string{"lower", "upper"} + + for _, req := range []struct { + OldPassword string + NewPassword string + Retype string + Message string + PasswordComplexity []string + }{ + { + OldPassword: oldPassword, + NewPassword: "Qwerty123456-", + Retype: "Qwerty123456-", + Message: "", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "12345", + Retype: "12345", + Message: "auth.password_too_short", + PasswordComplexity: pcALL, + }, + { + OldPassword: "12334", + NewPassword: "123456", + Retype: "123456", + Message: "settings.password_incorrect", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "123456", + Retype: "12345", + Message: "form.password_not_match", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "Qwerty", + Retype: "Qwerty", + Message: "form.password_complexity", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "Qwerty", + Retype: "Qwerty", + Message: "form.password_complexity", + PasswordComplexity: pcLUN, + }, + { + OldPassword: oldPassword, + NewPassword: "QWERTY", + Retype: "QWERTY", + Message: "form.password_complexity", + PasswordComplexity: pcLU, + }, + } { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user/settings/security") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + + web.SetForm(ctx, &forms.ChangePasswordForm{ + OldPassword: req.OldPassword, + Password: req.NewPassword, + Retype: req.Retype, + }) + AccountPost(ctx) + + assert.Contains(t, ctx.Flash.ErrorMsg, req.Message) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + } +} diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go new file mode 100644 index 0000000000..b2d918784f --- /dev/null +++ b/routers/web/user/setting/adopt.go @@ -0,0 +1,64 @@ +// Copyright 2020 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 setting + +import ( + "path/filepath" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// AdoptOrDeleteRepository adopts or deletes a repository +func AdoptOrDeleteRepository(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsRepos"] = true + allowAdopt := ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories + ctx.Data["allowAdopt"] = allowAdopt + allowDelete := ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories + ctx.Data["allowDelete"] = allowDelete + + dir := ctx.Query("id") + action := ctx.Query("action") + + ctxUser := ctx.User + root := filepath.Join(models.UserPath(ctxUser.LowerName)) + + // check not a repo + has, err := models.IsRepositoryExist(ctxUser, dir) + if err != nil { + ctx.ServerError("IsRepositoryExist", err) + return + } + + isDir, err := util.IsDir(filepath.Join(root, dir+".git")) + if err != nil { + ctx.ServerError("IsDir", err) + return + } + if has || !isDir { + // Fallthrough to failure mode + } else if action == "adopt" && allowAdopt { + if _, err := repository.AdoptRepository(ctxUser, ctxUser, models.CreateRepoOptions{ + Name: dir, + IsPrivate: true, + }); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir)) + } else if action == "delete" && allowDelete { + if err := repository.DeleteUnadoptedRepository(ctxUser, ctxUser, dir); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir)) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/repos") +} diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go new file mode 100644 index 0000000000..4161efdea4 --- /dev/null +++ b/routers/web/user/setting/applications.go @@ -0,0 +1,106 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 setting + +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" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsApplications base.TplName = "user/settings/applications" +) + +// Applications render manage access token page +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) +} + +// ApplicationsPost response for add user's access token +func ApplicationsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewAccessTokenForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + + t := &models.AccessToken{ + UID: ctx.User.ID, + Name: form.Name, + } + + exist, err := models.AccessTokenByNameExists(t) + if err != nil { + ctx.ServerError("AccessTokenByNameExists", err) + return + } + if exist { + ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name)) + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") + return + } + + if err := models.NewAccessToken(t); err != nil { + ctx.ServerError("NewAccessToken", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) + ctx.Flash.Info(t.Token) + + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") +} + +// DeleteApplication response for delete user access token +func DeleteApplication(ctx *context.Context) { + if err := models.DeleteAccessTokenByID(ctx.QueryInt64("id"), ctx.User.ID); err != nil { + ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/applications", + }) +} + +func loadApplicationsData(ctx *context.Context) { + tokens, err := models.ListAccessTokens(models.ListAccessTokensOptions{UserID: ctx.User.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + ctx.Data["EnableOAuth2"] = setting.OAuth2.Enable + if setting.OAuth2.Enable { + ctx.Data["Applications"], err = models.GetOAuth2ApplicationsByUserID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Grants"], err = models.GetOAuth2GrantsByUserID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetOAuth2GrantsByUserID", err) + return + } + } +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go new file mode 100644 index 0000000000..e56a33afcb --- /dev/null +++ b/routers/web/user/setting/keys.go @@ -0,0 +1,226 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 setting + +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" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsKeys base.TplName = "user/settings/keys" +) + +// Keys render user's SSH/GPG public keys page +func Keys(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer + ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + + loadKeysData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsKeys) +} + +// KeysPost response for change user's SSH/GPG keys +func KeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddKeyForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer + ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + + if ctx.HasError() { + loadKeysData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsKeys) + return + } + switch form.Type { + case "principal": + content, err := models.CheckPrincipalKeyString(ctx.User, form.Content) + if err != nil { + if models.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + if _, err = models.AddPrincipalKey(ctx.User.ID, content, 0); err != nil { + ctx.Data["HasPrincipalError"] = true + switch { + case models.IsErrKeyAlreadyExist(err), models.IsErrKeyNameAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPrincipalKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "gpg": + keys, err := models.AddGPGKey(ctx.User.ID, form.Content) + if err != nil { + ctx.Data["HasGPGError"] = true + switch { + case models.IsErrGPGKeyParsing(err): + ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error())) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case models.IsErrGPGKeyIDAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form) + case models.IsErrGPGNoEmailFound(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_no_key_email_found"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + keyIDs := "" + for _, key := range keys { + keyIDs += key.KeyID + keyIDs += ", " + } + if len(keyIDs) > 0 { + keyIDs = keyIDs[:len(keyIDs)-2] + } + ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", keyIDs)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "ssh": + content, err := models.CheckPublicKeyString(form.Content) + if err != nil { + if models.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if models.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + + if _, err = models.AddPublicKey(ctx.User.ID, form.Title, content, 0); err != nil { + ctx.Data["HasSSHError"] = true + switch { + case models.IsErrKeyAlreadyExist(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form) + case models.IsErrKeyNameAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form) + case models.IsErrKeyUnableVerify(err): + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } + +} + +// DeleteKey response for delete user's SSH/GPG key +func DeleteKey(ctx *context.Context) { + + switch ctx.Query("type") { + case "gpg": + if err := models.DeleteGPGKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteGPGKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) + } + case "ssh": + keyID := ctx.QueryInt64("id") + external, err := models.PublicKeyIsExternallyManaged(keyID) + if err != nil { + ctx.ServerError("sshKeysExternalManaged", err) + return + } + if external { + ctx.Flash.Error(ctx.Tr("setting.ssh_externally_managed")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + if err := models.DeletePublicKey(ctx.User, keyID); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) + } + case "principal": + if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success")) + } + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/keys", + }) +} + +func loadKeysData(ctx *context.Context) { + keys, err := models.ListPublicKeys(ctx.User.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["Keys"] = keys + + externalKeys, err := models.PublicKeysAreExternallyManaged(keys) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["ExternalKeys"] = externalKeys + + gpgkeys, err := models.ListGPGKeys(ctx.User.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + ctx.Data["GPGKeys"] = gpgkeys + + principals, err := models.ListPrincipalKeys(ctx.User.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListPrincipalKeys", err) + return + } + ctx.Data["Principals"] = principals +} diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go new file mode 100644 index 0000000000..daa3f7fe5b --- /dev/null +++ b/routers/web/user/setting/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 setting + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..", "..", "..")) +} diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go new file mode 100644 index 0000000000..c8db6e87f2 --- /dev/null +++ b/routers/web/user/setting/oauth2.go @@ -0,0 +1,159 @@ +// 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 setting + +import ( + "fmt" + "net/http" + + "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/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsOAuthApplications base.TplName = "user/settings/applications_oauth2_edit" +) + +// OAuthApplicationsPost response for adding a oauth2 application +func OAuthApplicationsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + // TODO validate redirect URI + app, err := models.CreateOAuth2Application(models.CreateOAuth2ApplicationOptions{ + Name: form.Name, + RedirectURIs: []string{form.RedirectURI}, + UserID: ctx.User.ID, + }) + if err != nil { + ctx.ServerError("CreateOAuth2Application", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success")) + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret() + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// OAuthApplicationsEdit response for editing oauth2 application +func OAuthApplicationsEdit(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + // TODO validate redirect URI + var err error + if ctx.Data["App"], err = models.UpdateOAuth2Application(models.UpdateOAuth2ApplicationOptions{ + ID: ctx.ParamsInt64("id"), + Name: form.Name, + RedirectURIs: []string{form.RedirectURI}, + UserID: ctx.User.ID, + }); err != nil { + ctx.ServerError("UpdateOAuth2Application", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret +func OAuthApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + app, err := models.GetOAuth2ApplicationByID(ctx.ParamsInt64("id")) + if err != nil { + if models.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != ctx.User.ID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret() + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// OAuth2ApplicationShow displays the given application +func OAuth2ApplicationShow(ctx *context.Context) { + app, err := models.GetOAuth2ApplicationByID(ctx.ParamsInt64("id")) + if err != nil { + if models.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != ctx.User.ID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// DeleteOAuth2Application deletes the given oauth2 application +func DeleteOAuth2Application(ctx *context.Context) { + if err := models.DeleteOAuth2Application(ctx.QueryInt64("id"), ctx.User.ID); err != nil { + ctx.ServerError("DeleteOAuth2Application", err) + return + } + log.Trace("OAuth2 Application deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/applications", + }) +} + +// RevokeOAuth2Grant revokes the grant with the given id +func RevokeOAuth2Grant(ctx *context.Context) { + if ctx.User.ID == 0 || ctx.QueryInt64("id") == 0 { + ctx.ServerError("RevokeOAuth2Grant", fmt.Errorf("user id or grant id is zero")) + return + } + if err := models.RevokeOAuth2Grant(ctx.QueryInt64("id"), ctx.User.ID); err != nil { + ctx.ServerError("RevokeOAuth2Grant", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/applications", + }) +} diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go new file mode 100644 index 0000000000..20042caca4 --- /dev/null +++ b/routers/web/user/setting/profile.go @@ -0,0 +1,319 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 setting + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "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/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/forms" + + "github.com/unknwon/i18n" +) + +const ( + tplSettingsProfile base.TplName = "user/settings/profile" + tplSettingsOrganization base.TplName = "user/settings/organization" + tplSettingsRepositories base.TplName = "user/settings/repos" +) + +// Profile render user's profile page +func Profile(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + + ctx.HTML(http.StatusOK, tplSettingsProfile) +} + +// HandleUsernameChange handle username changes from user settings and admin interface +func HandleUsernameChange(ctx *context.Context, user *models.User, newName string) error { + // Non-local users are not allowed to change their username. + if !user.IsLocal() { + ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) + return fmt.Errorf(ctx.Tr("form.username_change_not_local_user")) + } + + // Check if user name has been changed + if user.LowerName != strings.ToLower(newName) { + if err := models.ChangeUserName(user, newName); err != nil { + switch { + case models.IsErrUserAlreadyExist(err): + ctx.Flash.Error(ctx.Tr("form.username_been_taken")) + case models.IsErrEmailAlreadyUsed(err): + ctx.Flash.Error(ctx.Tr("form.email_been_used")) + case models.IsErrNameReserved(err): + ctx.Flash.Error(ctx.Tr("user.form.name_reserved", newName)) + case models.IsErrNamePatternNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", newName)) + case models.IsErrNameCharsNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", newName)) + default: + ctx.ServerError("ChangeUserName", err) + } + return err + } + } else { + if err := models.UpdateRepositoryOwnerNames(user.ID, newName); err != nil { + ctx.ServerError("UpdateRepository", err) + return err + } + } + log.Trace("User name changed: %s -> %s", user.Name, newName) + return nil +} + +// ProfilePost response for change user's profile +func ProfilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateProfileForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsProfile) + return + } + + if len(form.Name) != 0 && ctx.User.Name != form.Name { + log.Debug("Changing name for %s to %s", ctx.User.Name, form.Name) + if err := HandleUsernameChange(ctx, ctx.User, form.Name); err != nil { + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + ctx.User.Name = form.Name + ctx.User.LowerName = strings.ToLower(form.Name) + } + + ctx.User.FullName = form.FullName + ctx.User.KeepEmailPrivate = form.KeepEmailPrivate + ctx.User.Website = form.Website + ctx.User.Location = form.Location + if len(form.Language) != 0 { + if !util.IsStringInSlice(form.Language, setting.Langs) { + ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language)) + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + ctx.User.Language = form.Language + } + ctx.User.Description = form.Description + ctx.User.KeepActivityPrivate = form.KeepActivityPrivate + if err := models.UpdateUserSetting(ctx.User); err != nil { + if _, ok := err.(models.ErrEmailAlreadyUsed); ok { + ctx.Flash.Error(ctx.Tr("form.email_been_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + ctx.ServerError("UpdateUser", err) + return + } + + // Update the language to the one we just set + middleware.SetLocaleCookie(ctx.Resp, ctx.User.Language, 0) + + log.Trace("User settings updated: %s", ctx.User.Name) + ctx.Flash.Success(i18n.Tr(ctx.User.Language, "settings.update_profile_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// UpdateAvatarSetting update user's avatar +// FIXME: limit size. +func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *models.User) error { + ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal + if len(form.Gravatar) > 0 { + if form.Avatar != nil { + ctxUser.Avatar = base.EncodeMD5(form.Gravatar) + } else { + ctxUser.Avatar = "" + } + ctxUser.AvatarEmail = form.Gravatar + } + + if form.Avatar != nil && form.Avatar.Filename != "" { + fr, err := form.Avatar.Open() + if err != nil { + return fmt.Errorf("Avatar.Open: %v", err) + } + defer fr.Close() + + if form.Avatar.Size > setting.Avatar.MaxFileSize { + return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big")) + } + + data, err := ioutil.ReadAll(fr) + if err != nil { + return fmt.Errorf("ioutil.ReadAll: %v", err) + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) + } + if err = ctxUser.UploadAvatar(data); err != nil { + return fmt.Errorf("UploadAvatar: %v", err) + } + } else if ctxUser.UseCustomAvatar && ctxUser.Avatar == "" { + // No avatar is uploaded but setting has been changed to enable, + // generate a random one when needed. + if err := ctxUser.GenerateRandomAvatar(); err != nil { + log.Error("GenerateRandomAvatar[%d]: %v", ctxUser.ID, err) + } + } + + if err := models.UpdateUserCols(ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil { + return fmt.Errorf("UpdateUser: %v", err) + } + + return nil +} + +// AvatarPost response for change user's avatar request +func AvatarPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AvatarForm) + if err := UpdateAvatarSetting(ctx, form, ctx.User); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.update_avatar_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// DeleteAvatar render delete avatar page +func DeleteAvatar(ctx *context.Context) { + if err := ctx.User.DeleteAvatar(); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// Organization render all the organization of the user +func Organization(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsOrganization"] = true + orgs, err := models.GetOrgsByUserID(ctx.User.ID, ctx.IsSigned) + if err != nil { + ctx.ServerError("GetOrgsByUserID", err) + return + } + ctx.Data["Orgs"] = orgs + ctx.HTML(http.StatusOK, tplSettingsOrganization) +} + +// Repos display a list of all repositories of the user +func Repos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsRepos"] = true + ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories + ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories + + opts := models.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.QueryInt("page"), + } + + if opts.Page <= 0 { + opts.Page = 1 + } + start := (opts.Page - 1) * opts.PageSize + end := start + opts.PageSize + + adoptOrDelete := ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories) + + ctxUser := ctx.User + count := 0 + + if adoptOrDelete { + repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum) + repos := map[string]*models.Repository{} + // We're going to iterate by pagesize. + root := filepath.Join(models.UserPath(ctxUser.Name)) + if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() || path == root { + return nil + } + name := info.Name() + if !strings.HasSuffix(name, ".git") { + return filepath.SkipDir + } + name = name[:len(name)-4] + if models.IsUsableRepoName(name) != nil || strings.ToLower(name) != name { + return filepath.SkipDir + } + if count >= start && count < end { + repoNames = append(repoNames, name) + } + count++ + return filepath.SkipDir + }); err != nil { + ctx.ServerError("filepath.Walk", err) + return + } + + if err := ctxUser.GetRepositories(models.ListOptions{Page: 1, PageSize: setting.UI.Admin.UserPagingNum}, repoNames...); err != nil { + ctx.ServerError("GetRepositories", err) + return + } + for _, repo := range ctxUser.Repos { + if repo.IsFork { + if err := repo.GetBaseRepo(); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + repos[repo.LowerName] = repo + } + ctx.Data["Dirs"] = repoNames + ctx.Data["ReposMap"] = repos + } else { + var err error + var count64 int64 + ctxUser.Repos, count64, err = models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) + + if err != nil { + ctx.ServerError("GetRepositories", err) + return + } + count = int(count64) + repos := ctxUser.Repos + + for i := range repos { + if repos[i].IsFork { + if err := repos[i].GetBaseRepo(); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + } + + ctx.Data["Repos"] = repos + } + ctx.Data["Owner"] = ctxUser + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsRepositories) +} diff --git a/routers/web/user/setting/security.go b/routers/web/user/setting/security.go new file mode 100644 index 0000000000..7753c5c161 --- /dev/null +++ b/routers/web/user/setting/security.go @@ -0,0 +1,111 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 setting + +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 ( + tplSettingsSecurity base.TplName = "user/settings/security" + tplSettingsTwofaEnroll base.TplName = "user/settings/twofa_enroll" +) + +// Security render change user's password page and 2FA +func Security(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + ctx.Data["RequireU2F"] = true + + if ctx.Query("openid.return_to") != "" { + settingsOpenIDVerify(ctx) + return + } + + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) +} + +// DeleteAccountLink delete a single account link +func DeleteAccountLink(ctx *context.Context) { + id := ctx.QueryInt64("id") + if id <= 0 { + ctx.Flash.Error("Account link id is not given") + } else { + if _, err := models.RemoveAccountLink(ctx.User, id); err != nil { + ctx.Flash.Error("RemoveAccountLink: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + } + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) +} + +func loadSecurityData(ctx *context.Context) { + enrolled := true + _, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + enrolled = false + } else { + ctx.ServerError("SettingsTwoFactor", err) + return + } + } + ctx.Data["TwofaEnrolled"] = enrolled + if enrolled { + ctx.Data["U2FRegistrations"], err = models.GetU2FRegistrationsByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetU2FRegistrationsByUID", err) + return + } + } + + tokens, err := models.ListAccessTokens(models.ListAccessTokensOptions{UserID: ctx.User.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + + accountLinks, err := models.ListAccountLinks(ctx.User) + if err != nil { + ctx.ServerError("ListAccountLinks", err) + return + } + + // map the provider display name with the LoginSource + sources := make(map[*models.LoginSource]string) + for _, externalAccount := range accountLinks { + if loginSource, err := models.GetLoginSourceByID(externalAccount.LoginSourceID); err == nil { + var providerDisplayName string + if loginSource.IsOAuth2() { + providerTechnicalName := loginSource.OAuth2().Provider + providerDisplayName = models.OAuth2Providers[providerTechnicalName].DisplayName + } else { + providerDisplayName = loginSource.Name + } + sources[loginSource] = providerDisplayName + } + } + ctx.Data["AccountLinks"] = sources + + openid, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openid +} diff --git a/routers/web/user/setting/security_openid.go b/routers/web/user/setting/security_openid.go new file mode 100644 index 0000000000..74dba12825 --- /dev/null +++ b/routers/web/user/setting/security_openid.go @@ -0,0 +1,129 @@ +// 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 setting + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +// OpenIDPost response for change user's openid +func OpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddOpenIDForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + if ctx.HasError() { + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) + return + } + + // WARNING: specifying a wrong OpenID here could lock + // a user out of her account, would be better to + // verify/confirm the new OpenID before storing it + + // Also, consider allowing for multiple OpenID URIs + + id, err := openid.Normalize(form.Openid) + if err != nil { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + form.Openid = id + log.Trace("Normalized id: " + id) + + oids, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = oids + + // Check that the OpenID is not already used + for _, obj := range oids { + if obj.URI == id { + loadSecurityData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &form) + return + } + } + + redirectTo := setting.AppURL + "user/settings/security" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + ctx.Redirect(url) +} + +func settingsOpenIDVerify(ctx *context.Context) { + log.Trace("Incoming call to: " + ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + id, err := openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &forms.AddOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: " + id) + + oid := &models.UserOpenID{UID: ctx.User.ID, URI: id} + if err = models.AddUserOpenID(oid); err != nil { + if models.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &forms.AddOpenIDForm{Openid: id}) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + log.Trace("Associated OpenID %s to user %s", id, ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DeleteOpenID response for delete user's openid +func DeleteOpenID(ctx *context.Context) { + if err := models.DeleteUserOpenID(&models.UserOpenID{ID: ctx.QueryInt64("id"), UID: ctx.User.ID}); err != nil { + ctx.ServerError("DeleteUserOpenID", err) + return + } + log.Trace("OpenID address deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) +} + +// ToggleOpenIDVisibility response for toggle visibility of user's openid +func ToggleOpenIDVisibility(ctx *context.Context) { + if err := models.ToggleUserOpenIDVisibility(ctx.QueryInt64("id")); err != nil { + ctx.ServerError("ToggleUserOpenIDVisibility", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security_twofa.go b/routers/web/user/setting/security_twofa.go new file mode 100644 index 0000000000..7b08a05939 --- /dev/null +++ b/routers/web/user/setting/security_twofa.go @@ -0,0 +1,250 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// 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 setting + +import ( + "bytes" + "encoding/base64" + "html/template" + "image/png" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code. +func RegenerateScratchTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("setting.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to GenerateScratchToken", err) + return + } + + if err = models.UpdateTwoFactor(t); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to UpdateTwoFactor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DisableTwoFactor deletes the user's 2FA settings. +func DisableTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("setting.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + if err = models.DeleteTwoFactorByID(t.ID, ctx.User.ID); err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + // There is a potential DB race here - we must have been disabled by another request in the intervening period + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +func twofaGenerateSecretAndQr(ctx *context.Context) bool { + var otpKey *otp.Key + var err error + uri := ctx.Session.Get("twofaUri") + if uri != nil { + otpKey, err = otp.NewKeyFromURL(uri.(string)) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed NewKeyFromURL: ", err) + return false + } + } + // Filter unsafe character ':' in issuer + issuer := strings.ReplaceAll(setting.AppName+" ("+setting.Domain+")", ":", "") + if otpKey == nil { + otpKey, err = totp.Generate(totp.GenerateOpts{ + SecretSize: 40, + Issuer: issuer, + AccountName: ctx.User.Name, + }) + if err != nil { + ctx.ServerError("SettingsTwoFactor: totpGenerate Failed", err) + return false + } + } + + ctx.Data["TwofaSecret"] = otpKey.Secret() + img, err := otpKey.Image(320, 240) + if err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey image generation failed", err) + return false + } + + var imgBytes bytes.Buffer + if err = png.Encode(&imgBytes, img); err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey png encoding failed", err) + return false + } + + ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes())) + + if err := ctx.Session.Set("twofaSecret", otpKey.Secret()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaSecret", err) + return false + } + + if err := ctx.Session.Set("twofaUri", otpKey.String()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaUri", err) + return false + } + + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + return true +} + +// EnrollTwoFactor shows the page where the user can enroll into 2FA. +func EnrollTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if t != nil { + // already enrolled - we should redirect back! + log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.User) + ctx.Flash.Error(ctx.Tr("setting.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err) + return + } + + if !twofaGenerateSecretAndQr(ctx) { + return + } + + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) +} + +// EnrollTwoFactorPost handles enrolling the user into 2FA. +func EnrollTwoFactorPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if t != nil { + // already enrolled + ctx.Flash.Error(ctx.Tr("setting.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: Failed to check if already enrolled with GetTwoFactorByUID", err) + return + } + + if ctx.HasError() { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) + return + } + + secretRaw := ctx.Session.Get("twofaSecret") + if secretRaw == nil { + ctx.Flash.Error(ctx.Tr("settings.twofa_failed_get_secret")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + secret := secretRaw.(string) + if !totp.Validate(form.Passcode, secret) { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.Flash.Error(ctx.Tr("settings.passcode_invalid")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + t = &models.TwoFactor{ + UID: ctx.User.ID, + } + err = t.SetSecret(secret) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set secret", err) + return + } + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to generate scratch token", err) + return + } + + // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used + // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor + if err := ctx.Session.Delete("twofaSecret"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaSecret from the session: Error: %v", err) + } + if err := ctx.Session.Delete("twofaUri"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaUri from the session: Error: %v", err) + } + if err := ctx.Session.Release(); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to save changes to the session: %v", err) + } + + if err = models.NewTwoFactor(t); err != nil { + // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. + // If there is a unique constraint fail we should just tolerate the error + ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security_u2f.go b/routers/web/user/setting/security_u2f.go new file mode 100644 index 0000000000..f9e35549fb --- /dev/null +++ b/routers/web/user/setting/security_u2f.go @@ -0,0 +1,111 @@ +// 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 setting + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/tstranex/u2f" +) + +// U2FRegister initializes the u2f registration procedure +func U2FRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.U2FRegistrationForm) + if form.Name == "" { + ctx.Error(http.StatusConflict) + return + } + challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets) + if err != nil { + ctx.ServerError("NewChallenge", err) + return + } + if err := ctx.Session.Set("u2fChallenge", challenge); err != nil { + ctx.ServerError("Unable to set session key for u2fChallenge", err) + return + } + regs, err := models.GetU2FRegistrationsByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetU2FRegistrationsByUID", err) + return + } + for _, reg := range regs { + if reg.Name == form.Name { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + } + if err := ctx.Session.Set("u2fName", form.Name); err != nil { + ctx.ServerError("Unable to set session key for u2fName", err) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + ctx.JSON(http.StatusOK, u2f.NewWebRegisterRequest(challenge, regs.ToRegistrations())) +} + +// U2FRegisterPost receives the response of the security key +func U2FRegisterPost(ctx *context.Context) { + response := web.GetForm(ctx).(*u2f.RegisterResponse) + challSess := ctx.Session.Get("u2fChallenge") + u2fName := ctx.Session.Get("u2fName") + if challSess == nil || u2fName == nil { + ctx.ServerError("U2FRegisterPost", errors.New("not in U2F session")) + return + } + challenge := challSess.(*u2f.Challenge) + name := u2fName.(string) + config := &u2f.Config{ + // Chrome 66+ doesn't return the device's attestation + // certificate by default. + SkipAttestationVerify: true, + } + reg, err := u2f.Register(*response, *challenge, config) + if err != nil { + ctx.ServerError("u2f.Register", err) + return + } + if _, err = models.CreateRegistration(ctx.User, name, reg); err != nil { + ctx.ServerError("u2f.Register", err) + return + } + ctx.Status(200) +} + +// U2FDelete deletes an security key by id +func U2FDelete(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.U2FDeleteForm) + reg, err := models.GetU2FRegistrationByID(form.ID) + if err != nil { + if models.IsErrU2FRegistrationNotExist(err) { + ctx.Status(200) + return + } + ctx.ServerError("GetU2FRegistrationByID", err) + return + } + if reg.UserID != ctx.User.ID { + ctx.Status(401) + return + } + if err := models.DeleteRegistration(reg); err != nil { + ctx.ServerError("DeleteRegistration", err) + return + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) +} diff --git a/routers/web/user/task.go b/routers/web/user/task.go new file mode 100644 index 0000000000..b8df5d99c7 --- /dev/null +++ b/routers/web/user/task.go @@ -0,0 +1,32 @@ +// Copyright 2020 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 user + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" +) + +// TaskStatus returns task's status +func TaskStatus(ctx *context.Context) { + task, opts, err := models.GetMigratingTaskByID(ctx.ParamsInt64("task"), ctx.User.ID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "err": err, + }) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "status": task.Status, + "err": task.Errors, + "repo-id": task.RepoID, + "repo-name": opts.RepoName, + "start": task.StartTime, + "end": task.EndTime, + }) +} diff --git a/routers/web/web.go b/routers/web/web.go new file mode 100644 index 0000000000..6c0141eef3 --- /dev/null +++ b/routers/web/web.go @@ -0,0 +1,1023 @@ +// 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 web + +import ( + "encoding/gob" + "net/http" + "os" + "path" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/metrics" + "code.gitea.io/gitea/modules/public" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/misc" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/routers/web/admin" + "code.gitea.io/gitea/routers/web/dev" + "code.gitea.io/gitea/routers/web/events" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/routers/web/org" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/routers/web/user" + userSetting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/lfs" + "code.gitea.io/gitea/services/mailer" + + // to registers all internal adapters + _ "code.gitea.io/gitea/modules/session" + + "gitea.com/go-chi/captcha" + "gitea.com/go-chi/session" + "github.com/NYTimes/gziphandler" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/cors" + "github.com/prometheus/client_golang/prometheus" + "github.com/tstranex/u2f" +) + +const ( + // GzipMinSize represents min size to compress for the body size of response + GzipMinSize = 1400 +) + +// CorsHandler return a http handler who set CORS options if enabled by config +func CorsHandler() func(next http.Handler) http.Handler { + if setting.CORSConfig.Enabled { + return cors.Handler(cors.Options{ + //Scheme: setting.CORSConfig.Scheme, // FIXME: the cors middleware needs scheme option + AllowedOrigins: setting.CORSConfig.AllowDomain, + //setting.CORSConfig.AllowSubdomain // FIXME: the cors middleware needs allowSubdomain option + AllowedMethods: setting.CORSConfig.Methods, + AllowCredentials: setting.CORSConfig.AllowCredentials, + MaxAge: int(setting.CORSConfig.MaxAge.Seconds()), + }) + } + + return func(next http.Handler) http.Handler { + return next + } +} + +// Routes returns all web routes +func Routes() *web.Route { + routes := web.NewRoute() + + routes.Use(public.AssetsHandler(&public.Options{ + Directory: path.Join(setting.StaticRootPath, "public"), + Prefix: "/assets", + CorsHandler: CorsHandler(), + })) + + routes.Use(session.Sessioner(session.Options{ + Provider: setting.SessionConfig.Provider, + ProviderConfig: setting.SessionConfig.ProviderConfig, + CookieName: setting.SessionConfig.CookieName, + CookiePath: setting.SessionConfig.CookiePath, + Gclifetime: setting.SessionConfig.Gclifetime, + Maxlifetime: setting.SessionConfig.Maxlifetime, + Secure: setting.SessionConfig.Secure, + SameSite: setting.SessionConfig.SameSite, + Domain: setting.SessionConfig.Domain, + })) + + routes.Use(Recovery()) + + // We use r.Route here over r.Use because this prevents requests that are not for avatars having to go through this additional handler + routes.Route("/avatars/*", "GET, HEAD", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) + routes.Route("/repo-avatars/*", "GET, HEAD", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) + + // for health check - doeesn't need to be passed through gzip handler + routes.Head("/", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // this png is very likely to always be below the limit for gzip so it doesn't need to pass through gzip + routes.Get("/apple-touch-icon.png", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, path.Join(setting.StaticURLPrefix, "/assets/img/apple-touch-icon.png"), 301) + }) + + gob.Register(&u2f.Challenge{}) + + common := []interface{}{} + + if setting.EnableGzip { + h, err := gziphandler.GzipHandlerWithOpts(gziphandler.MinSize(GzipMinSize)) + if err != nil { + log.Fatal("GzipHandlerWithOpts failed: %v", err) + } + common = append(common, h) + } + + mailer.InitMailRender(templates.Mailer()) + + if setting.Service.EnableCaptcha { + // The captcha http.Handler should only fire on /captcha/* so we can just mount this on that url + routes.Route("/captcha/*", "GET,HEAD", append(common, captcha.Captchaer(context.GetImageCaptcha()))...) + } + + if setting.HasRobotsTxt { + routes.Get("/robots.txt", append(common, func(w http.ResponseWriter, req *http.Request) { + filePath := path.Join(setting.CustomPath, "robots.txt") + fi, err := os.Stat(filePath) + if err == nil && httpcache.HandleTimeCache(req, w, fi) { + return + } + http.ServeFile(w, req, filePath) + })...) + } + + // prometheus metrics endpoint - do not need to go through contexter + if setting.Metrics.Enabled { + c := metrics.NewCollector() + prometheus.MustRegister(c) + + routes.Get("/metrics", append(common, Metrics)...) + } + + // Removed: toolbox.Toolboxer middleware will provide debug informations which seems unnecessary + common = append(common, context.Contexter()) + + // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route + common = append(common, middleware.GetHead) + + if setting.API.EnableSwagger { + // Note: The route moved from apiroutes because it's in fact want to render a web page + routes.Get("/api/swagger", append(common, misc.Swagger)...) // Render V1 by default + } + + // TODO: These really seem like things that could be folded into Contexter or as helper functions + common = append(common, user.GetNotificationCount) + common = append(common, repo.GetActiveStopwatch) + common = append(common, goGet) + + others := web.NewRoute() + for _, middle := range common { + others.Use(middle) + } + + RegisterRoutes(others) + routes.Mount("", others) + return routes +} + +// RegisterRoutes register routes +func RegisterRoutes(m *web.Route) { + reqSignIn := context.Toggle(&context.ToggleOptions{SignInRequired: true}) + ignSignIn := context.Toggle(&context.ToggleOptions{SignInRequired: setting.Service.RequireSignInView}) + ignExploreSignIn := context.Toggle(&context.ToggleOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) + ignSignInAndCsrf := context.Toggle(&context.ToggleOptions{DisableCSRF: true}) + reqSignOut := context.Toggle(&context.ToggleOptions{SignOutRequired: true}) + + //bindIgnErr := binding.BindIgnErr + bindIgnErr := web.Bind + validation.AddBindingRules() + + openIDSignInEnabled := func(ctx *context.Context) { + if !setting.Service.EnableOpenIDSignIn { + ctx.Error(http.StatusForbidden) + return + } + } + + openIDSignUpEnabled := func(ctx *context.Context) { + if !setting.Service.EnableOpenIDSignUp { + ctx.Error(http.StatusForbidden) + return + } + } + + reqMilestonesDashboardPageEnabled := func(ctx *context.Context) { + if !setting.Service.ShowMilestonesDashboardPage { + ctx.Error(http.StatusForbidden) + return + } + } + + // webhooksEnabled requires webhooks to be enabled by admin. + webhooksEnabled := func(ctx *context.Context) { + if setting.DisableWebhooks { + ctx.Error(http.StatusForbidden) + return + } + } + + lfsServerEnabled := func(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.Error(http.StatusNotFound) + return + } + } + + // FIXME: not all routes need go through same middleware. + // Especially some AJAX requests, we can reduce middleware number to improve performance. + // Routers. + // for health check + m.Get("/", Home) + m.Get("/.well-known/openid-configuration", user.OIDCWellKnown) + m.Group("/explore", func() { + m.Get("", func(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/explore/repos") + }) + m.Get("/repos", explore.Repos) + m.Get("/users", explore.Users) + m.Get("/organizations", explore.Organizations) + m.Get("/code", explore.Code) + }, ignExploreSignIn) + m.Get("/issues", reqSignIn, user.Issues) + m.Get("/pulls", reqSignIn, user.Pulls) + m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) + + // ***** START: User ***** + m.Group("/user", func() { + m.Get("/login", user.SignIn) + m.Post("/login", bindIgnErr(forms.SignInForm{}), user.SignInPost) + m.Group("", func() { + m.Combo("/login/openid"). + Get(user.SignInOpenID). + Post(bindIgnErr(forms.SignInOpenIDForm{}), user.SignInOpenIDPost) + }, openIDSignInEnabled) + m.Group("/openid", func() { + m.Combo("/connect"). + Get(user.ConnectOpenID). + Post(bindIgnErr(forms.ConnectOpenIDForm{}), user.ConnectOpenIDPost) + m.Group("/register", func() { + m.Combo(""). + Get(user.RegisterOpenID, openIDSignUpEnabled). + Post(bindIgnErr(forms.SignUpOpenIDForm{}), user.RegisterOpenIDPost) + }, openIDSignUpEnabled) + }, openIDSignInEnabled) + m.Get("/sign_up", user.SignUp) + m.Post("/sign_up", bindIgnErr(forms.RegisterForm{}), user.SignUpPost) + m.Group("/oauth2", func() { + m.Get("/{provider}", user.SignInOAuth) + m.Get("/{provider}/callback", user.SignInOAuthCallback) + }) + m.Get("/link_account", user.LinkAccount) + m.Post("/link_account_signin", bindIgnErr(forms.SignInForm{}), user.LinkAccountPostSignIn) + m.Post("/link_account_signup", bindIgnErr(forms.RegisterForm{}), user.LinkAccountPostRegister) + m.Group("/two_factor", func() { + m.Get("", user.TwoFactor) + m.Post("", bindIgnErr(forms.TwoFactorAuthForm{}), user.TwoFactorPost) + m.Get("/scratch", user.TwoFactorScratch) + m.Post("/scratch", bindIgnErr(forms.TwoFactorScratchAuthForm{}), user.TwoFactorScratchPost) + }) + m.Group("/u2f", func() { + m.Get("", user.U2F) + m.Get("/challenge", user.U2FChallenge) + m.Post("/sign", bindIgnErr(u2f.SignResponse{}), user.U2FSign) + + }) + }, reqSignOut) + + m.Any("/user/events", events.Events) + + m.Group("/login/oauth", func() { + m.Get("/authorize", bindIgnErr(forms.AuthorizationForm{}), user.AuthorizeOAuth) + m.Post("/grant", bindIgnErr(forms.GrantApplicationForm{}), user.GrantApplicationOAuth) + // TODO manage redirection + m.Post("/authorize", bindIgnErr(forms.AuthorizationForm{}), user.AuthorizeOAuth) + }, ignSignInAndCsrf, reqSignIn) + m.Get("/login/oauth/userinfo", ignSignInAndCsrf, user.InfoOAuth) + m.Post("/login/oauth/access_token", CorsHandler(), bindIgnErr(forms.AccessTokenForm{}), ignSignInAndCsrf, user.AccessTokenOAuth) + + m.Group("/user/settings", func() { + m.Get("", userSetting.Profile) + m.Post("", bindIgnErr(forms.UpdateProfileForm{}), userSetting.ProfilePost) + m.Get("/change_password", user.MustChangePassword) + m.Post("/change_password", bindIgnErr(forms.MustChangePasswordForm{}), user.MustChangePasswordPost) + m.Post("/avatar", bindIgnErr(forms.AvatarForm{}), userSetting.AvatarPost) + m.Post("/avatar/delete", userSetting.DeleteAvatar) + m.Group("/account", func() { + m.Combo("").Get(userSetting.Account).Post(bindIgnErr(forms.ChangePasswordForm{}), userSetting.AccountPost) + m.Post("/email", bindIgnErr(forms.AddEmailForm{}), userSetting.EmailPost) + m.Post("/email/delete", userSetting.DeleteEmail) + m.Post("/delete", userSetting.DeleteAccount) + m.Post("/theme", bindIgnErr(forms.UpdateThemeForm{}), userSetting.UpdateUIThemePost) + }) + m.Group("/security", func() { + m.Get("", userSetting.Security) + m.Group("/two_factor", func() { + m.Post("/regenerate_scratch", userSetting.RegenerateScratchTwoFactor) + m.Post("/disable", userSetting.DisableTwoFactor) + m.Get("/enroll", userSetting.EnrollTwoFactor) + m.Post("/enroll", bindIgnErr(forms.TwoFactorAuthForm{}), userSetting.EnrollTwoFactorPost) + }) + m.Group("/u2f", func() { + m.Post("/request_register", bindIgnErr(forms.U2FRegistrationForm{}), userSetting.U2FRegister) + m.Post("/register", bindIgnErr(u2f.RegisterResponse{}), userSetting.U2FRegisterPost) + m.Post("/delete", bindIgnErr(forms.U2FDeleteForm{}), userSetting.U2FDelete) + }) + m.Group("/openid", func() { + m.Post("", bindIgnErr(forms.AddOpenIDForm{}), userSetting.OpenIDPost) + m.Post("/delete", userSetting.DeleteOpenID) + m.Post("/toggle_visibility", userSetting.ToggleOpenIDVisibility) + }, openIDSignInEnabled) + m.Post("/account_link", userSetting.DeleteAccountLink) + }) + m.Group("/applications/oauth2", func() { + m.Get("/{id}", userSetting.OAuth2ApplicationShow) + m.Post("/{id}", bindIgnErr(forms.EditOAuth2ApplicationForm{}), userSetting.OAuthApplicationsEdit) + m.Post("/{id}/regenerate_secret", userSetting.OAuthApplicationsRegenerateSecret) + m.Post("", bindIgnErr(forms.EditOAuth2ApplicationForm{}), userSetting.OAuthApplicationsPost) + m.Post("/delete", userSetting.DeleteOAuth2Application) + m.Post("/revoke", userSetting.RevokeOAuth2Grant) + }) + m.Combo("/applications").Get(userSetting.Applications). + Post(bindIgnErr(forms.NewAccessTokenForm{}), userSetting.ApplicationsPost) + m.Post("/applications/delete", userSetting.DeleteApplication) + m.Combo("/keys").Get(userSetting.Keys). + Post(bindIgnErr(forms.AddKeyForm{}), userSetting.KeysPost) + m.Post("/keys/delete", userSetting.DeleteKey) + m.Get("/organization", userSetting.Organization) + m.Get("/repos", userSetting.Repos) + m.Post("/repos/unadopted", userSetting.AdoptOrDeleteRepository) + }, reqSignIn, func(ctx *context.Context) { + ctx.Data["PageIsUserSettings"] = true + ctx.Data["AllThemes"] = setting.UI.Themes + }) + + m.Group("/user", func() { + // r.Get("/feeds", binding.Bind(auth.FeedsForm{}), user.Feeds) + m.Get("/activate", user.Activate, reqSignIn) + m.Post("/activate", user.ActivatePost, reqSignIn) + m.Any("/activate_email", user.ActivateEmail) + m.Get("/avatar/{username}/{size}", user.Avatar) + m.Get("/email2user", user.Email2User) + m.Get("/recover_account", user.ResetPasswd) + m.Post("/recover_account", user.ResetPasswdPost) + m.Get("/forgot_password", user.ForgotPasswd) + m.Post("/forgot_password", user.ForgotPasswdPost) + m.Post("/logout", user.SignOut) + m.Get("/task/{task}", user.TaskStatus) + }) + // ***** END: User ***** + + m.Get("/avatar/{hash}", user.AvatarByEmailHash) + + adminReq := context.Toggle(&context.ToggleOptions{SignInRequired: true, AdminRequired: true}) + + // ***** START: Admin ***** + m.Group("/admin", func() { + m.Get("", adminReq, admin.Dashboard) + m.Post("", adminReq, bindIgnErr(forms.AdminDashboardForm{}), admin.DashboardPost) + m.Get("/config", admin.Config) + m.Post("/config/test_mail", admin.SendTestMail) + m.Group("/monitor", func() { + m.Get("", admin.Monitor) + m.Post("/cancel/{pid}", admin.MonitorCancel) + m.Group("/queue/{qid}", func() { + m.Get("", admin.Queue) + m.Post("/set", admin.SetQueueSettings) + m.Post("/add", admin.AddWorkers) + m.Post("/cancel/{pid}", admin.WorkerCancel) + m.Post("/flush", admin.Flush) + }) + }) + + m.Group("/users", func() { + m.Get("", admin.Users) + m.Combo("/new").Get(admin.NewUser).Post(bindIgnErr(forms.AdminCreateUserForm{}), admin.NewUserPost) + m.Combo("/{userid}").Get(admin.EditUser).Post(bindIgnErr(forms.AdminEditUserForm{}), admin.EditUserPost) + m.Post("/{userid}/delete", admin.DeleteUser) + }) + + m.Group("/emails", func() { + m.Get("", admin.Emails) + m.Post("/activate", admin.ActivateEmail) + }) + + m.Group("/orgs", func() { + m.Get("", admin.Organizations) + }) + + m.Group("/repos", func() { + m.Get("", admin.Repos) + m.Combo("/unadopted").Get(admin.UnadoptedRepos).Post(admin.AdoptOrDeleteRepository) + m.Post("/delete", admin.DeleteRepo) + }) + + m.Group("/hooks", func() { + m.Get("", admin.DefaultOrSystemWebhooks) + m.Post("/delete", admin.DeleteDefaultOrSystemWebhook) + m.Get("/{id}", repo.WebHooksEdit) + m.Post("/gitea/{id}", bindIgnErr(forms.NewWebhookForm{}), repo.WebHooksEditPost) + m.Post("/gogs/{id}", bindIgnErr(forms.NewGogshookForm{}), repo.GogsHooksEditPost) + m.Post("/slack/{id}", bindIgnErr(forms.NewSlackHookForm{}), repo.SlackHooksEditPost) + m.Post("/discord/{id}", bindIgnErr(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost) + m.Post("/dingtalk/{id}", bindIgnErr(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost) + m.Post("/telegram/{id}", bindIgnErr(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost) + m.Post("/matrix/{id}", bindIgnErr(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost) + m.Post("/msteams/{id}", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) + m.Post("/feishu/{id}", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) + }, webhooksEnabled) + + m.Group("/{configType:default-hooks|system-hooks}", func() { + m.Get("/{type}/new", repo.WebhooksNew) + m.Post("/gitea/new", bindIgnErr(forms.NewWebhookForm{}), repo.GiteaHooksNewPost) + m.Post("/gogs/new", bindIgnErr(forms.NewGogshookForm{}), repo.GogsHooksNewPost) + m.Post("/slack/new", bindIgnErr(forms.NewSlackHookForm{}), repo.SlackHooksNewPost) + m.Post("/discord/new", bindIgnErr(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost) + m.Post("/dingtalk/new", bindIgnErr(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost) + m.Post("/telegram/new", bindIgnErr(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost) + m.Post("/matrix/new", bindIgnErr(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost) + m.Post("/msteams/new", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) + m.Post("/feishu/new", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) + }) + + m.Group("/auths", func() { + m.Get("", admin.Authentications) + m.Combo("/new").Get(admin.NewAuthSource).Post(bindIgnErr(forms.AuthenticationForm{}), admin.NewAuthSourcePost) + m.Combo("/{authid}").Get(admin.EditAuthSource). + Post(bindIgnErr(forms.AuthenticationForm{}), admin.EditAuthSourcePost) + m.Post("/{authid}/delete", admin.DeleteAuthSource) + }) + + m.Group("/notices", func() { + m.Get("", admin.Notices) + m.Post("/delete", admin.DeleteNotices) + m.Post("/empty", admin.EmptyNotices) + }) + }, adminReq) + // ***** END: Admin ***** + + m.Group("", func() { + m.Get("/{username}", user.Profile) + m.Get("/attachments/{uuid}", repo.GetAttachment) + }, ignSignIn) + + m.Group("/{username}", func() { + m.Post("/action/{action}", user.Action) + }, reqSignIn) + + if !setting.IsProd() { + m.Get("/template/*", dev.TemplatePreview) + } + + reqRepoAdmin := context.RequireRepoAdmin() + reqRepoCodeWriter := context.RequireRepoWriter(models.UnitTypeCode) + reqRepoCodeReader := context.RequireRepoReader(models.UnitTypeCode) + reqRepoReleaseWriter := context.RequireRepoWriter(models.UnitTypeReleases) + reqRepoReleaseReader := context.RequireRepoReader(models.UnitTypeReleases) + reqRepoWikiWriter := context.RequireRepoWriter(models.UnitTypeWiki) + reqRepoIssueWriter := context.RequireRepoWriter(models.UnitTypeIssues) + reqRepoIssueReader := context.RequireRepoReader(models.UnitTypeIssues) + reqRepoPullsReader := context.RequireRepoReader(models.UnitTypePullRequests) + reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(models.UnitTypeIssues, models.UnitTypePullRequests) + reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(models.UnitTypeIssues, models.UnitTypePullRequests) + reqRepoProjectsReader := context.RequireRepoReader(models.UnitTypeProjects) + reqRepoProjectsWriter := context.RequireRepoWriter(models.UnitTypeProjects) + + // ***** START: Organization ***** + m.Group("/org", func() { + m.Group("", func() { + m.Get("/create", org.Create) + m.Post("/create", bindIgnErr(forms.CreateOrgForm{}), org.CreatePost) + }) + + m.Group("/{org}", func() { + m.Get("/dashboard", user.Dashboard) + m.Get("/dashboard/{team}", user.Dashboard) + m.Get("/issues", user.Issues) + m.Get("/issues/{team}", user.Issues) + m.Get("/pulls", user.Pulls) + m.Get("/pulls/{team}", user.Pulls) + m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones) + m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones) + m.Get("/members", org.Members) + m.Post("/members/action/{action}", org.MembersAction) + m.Get("/teams", org.Teams) + }, context.OrgAssignment(true, false, true)) + + m.Group("/{org}", func() { + m.Get("/teams/{team}", org.TeamMembers) + m.Get("/teams/{team}/repositories", org.TeamRepositories) + m.Post("/teams/{team}/action/{action}", org.TeamsAction) + m.Post("/teams/{team}/action/repo/{action}", org.TeamsRepoAction) + }, context.OrgAssignment(true, false, true)) + + m.Group("/{org}", func() { + m.Get("/teams/new", org.NewTeam) + m.Post("/teams/new", bindIgnErr(forms.CreateTeamForm{}), org.NewTeamPost) + m.Get("/teams/{team}/edit", org.EditTeam) + m.Post("/teams/{team}/edit", bindIgnErr(forms.CreateTeamForm{}), org.EditTeamPost) + m.Post("/teams/{team}/delete", org.DeleteTeam) + + m.Group("/settings", func() { + m.Combo("").Get(org.Settings). + Post(bindIgnErr(forms.UpdateOrgSettingForm{}), org.SettingsPost) + m.Post("/avatar", bindIgnErr(forms.AvatarForm{}), org.SettingsAvatar) + m.Post("/avatar/delete", org.SettingsDeleteAvatar) + + m.Group("/hooks", func() { + m.Get("", org.Webhooks) + m.Post("/delete", org.DeleteWebhook) + m.Get("/{type}/new", repo.WebhooksNew) + m.Post("/gitea/new", bindIgnErr(forms.NewWebhookForm{}), repo.GiteaHooksNewPost) + m.Post("/gogs/new", bindIgnErr(forms.NewGogshookForm{}), repo.GogsHooksNewPost) + m.Post("/slack/new", bindIgnErr(forms.NewSlackHookForm{}), repo.SlackHooksNewPost) + m.Post("/discord/new", bindIgnErr(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost) + m.Post("/dingtalk/new", bindIgnErr(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost) + m.Post("/telegram/new", bindIgnErr(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost) + m.Post("/matrix/new", bindIgnErr(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost) + m.Post("/msteams/new", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) + m.Post("/feishu/new", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) + m.Get("/{id}", repo.WebHooksEdit) + m.Post("/gitea/{id}", bindIgnErr(forms.NewWebhookForm{}), repo.WebHooksEditPost) + m.Post("/gogs/{id}", bindIgnErr(forms.NewGogshookForm{}), repo.GogsHooksEditPost) + m.Post("/slack/{id}", bindIgnErr(forms.NewSlackHookForm{}), repo.SlackHooksEditPost) + m.Post("/discord/{id}", bindIgnErr(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost) + m.Post("/dingtalk/{id}", bindIgnErr(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost) + m.Post("/telegram/{id}", bindIgnErr(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost) + m.Post("/matrix/{id}", bindIgnErr(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost) + m.Post("/msteams/{id}", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) + m.Post("/feishu/{id}", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) + }, webhooksEnabled) + + m.Group("/labels", func() { + m.Get("", org.RetrieveLabels, org.Labels) + m.Post("/new", bindIgnErr(forms.CreateLabelForm{}), org.NewLabel) + m.Post("/edit", bindIgnErr(forms.CreateLabelForm{}), org.UpdateLabel) + m.Post("/delete", org.DeleteLabel) + m.Post("/initialize", bindIgnErr(forms.InitializeLabelsForm{}), org.InitializeLabels) + }) + + m.Route("/delete", "GET,POST", org.SettingsDelete) + }) + }, context.OrgAssignment(true, true)) + }, reqSignIn) + // ***** END: Organization ***** + + // ***** START: Repository ***** + m.Group("/repo", func() { + m.Get("/create", repo.Create) + m.Post("/create", bindIgnErr(forms.CreateRepoForm{}), repo.CreatePost) + m.Get("/migrate", repo.Migrate) + m.Post("/migrate", bindIgnErr(forms.MigrateRepoForm{}), repo.MigratePost) + m.Group("/fork", func() { + m.Combo("/{repoid}").Get(repo.Fork). + Post(bindIgnErr(forms.CreateRepoForm{}), repo.ForkPost) + }, context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader) + }, reqSignIn) + + // ***** Release Attachment Download without Signin + m.Get("/{username}/{reponame}/releases/download/{vTag}/{fileName}", ignSignIn, context.RepoAssignment, repo.MustBeNotEmpty, repo.RedirectDownload) + + m.Group("/{username}/{reponame}", func() { + m.Group("/settings", func() { + m.Combo("").Get(repo.Settings). + Post(bindIgnErr(forms.RepoSettingForm{}), repo.SettingsPost) + m.Post("/avatar", bindIgnErr(forms.AvatarForm{}), repo.SettingsAvatar) + m.Post("/avatar/delete", repo.SettingsDeleteAvatar) + + m.Group("/collaboration", func() { + m.Combo("").Get(repo.Collaboration).Post(repo.CollaborationPost) + m.Post("/access_mode", repo.ChangeCollaborationAccessMode) + m.Post("/delete", repo.DeleteCollaboration) + m.Group("/team", func() { + m.Post("", repo.AddTeamPost) + m.Post("/delete", repo.DeleteTeam) + }) + }) + m.Group("/branches", func() { + m.Combo("").Get(repo.ProtectedBranch).Post(repo.ProtectedBranchPost) + m.Combo("/*").Get(repo.SettingsProtectedBranch). + Post(bindIgnErr(forms.ProtectBranchForm{}), context.RepoMustNotBeArchived(), repo.SettingsProtectedBranchPost) + }, repo.MustBeNotEmpty) + + m.Group("/hooks/git", func() { + m.Get("", repo.GitHooks) + m.Combo("/{name}").Get(repo.GitHooksEdit). + Post(repo.GitHooksEditPost) + }, context.GitHookService()) + + m.Group("/hooks", func() { + m.Get("", repo.Webhooks) + m.Post("/delete", repo.DeleteWebhook) + m.Get("/{type}/new", repo.WebhooksNew) + m.Post("/gitea/new", bindIgnErr(forms.NewWebhookForm{}), repo.GiteaHooksNewPost) + m.Post("/gogs/new", bindIgnErr(forms.NewGogshookForm{}), repo.GogsHooksNewPost) + m.Post("/slack/new", bindIgnErr(forms.NewSlackHookForm{}), repo.SlackHooksNewPost) + m.Post("/discord/new", bindIgnErr(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost) + m.Post("/dingtalk/new", bindIgnErr(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost) + m.Post("/telegram/new", bindIgnErr(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost) + m.Post("/matrix/new", bindIgnErr(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost) + m.Post("/msteams/new", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) + m.Post("/feishu/new", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) + m.Get("/{id}", repo.WebHooksEdit) + m.Post("/{id}/test", repo.TestWebhook) + m.Post("/gitea/{id}", bindIgnErr(forms.NewWebhookForm{}), repo.WebHooksEditPost) + m.Post("/gogs/{id}", bindIgnErr(forms.NewGogshookForm{}), repo.GogsHooksEditPost) + m.Post("/slack/{id}", bindIgnErr(forms.NewSlackHookForm{}), repo.SlackHooksEditPost) + m.Post("/discord/{id}", bindIgnErr(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost) + m.Post("/dingtalk/{id}", bindIgnErr(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost) + m.Post("/telegram/{id}", bindIgnErr(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost) + m.Post("/matrix/{id}", bindIgnErr(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost) + m.Post("/msteams/{id}", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) + m.Post("/feishu/{id}", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) + }, webhooksEnabled) + + m.Group("/keys", func() { + m.Combo("").Get(repo.DeployKeys). + Post(bindIgnErr(forms.AddKeyForm{}), repo.DeployKeysPost) + m.Post("/delete", repo.DeleteDeployKey) + }) + + m.Group("/lfs", func() { + m.Get("/", repo.LFSFiles) + m.Get("/show/{oid}", repo.LFSFileGet) + m.Post("/delete/{oid}", repo.LFSDelete) + m.Get("/pointers", repo.LFSPointerFiles) + m.Post("/pointers/associate", repo.LFSAutoAssociate) + m.Get("/find", repo.LFSFileFind) + m.Group("/locks", func() { + m.Get("/", repo.LFSLocks) + m.Post("/", repo.LFSLockFile) + m.Post("/{lid}/unlock", repo.LFSUnlock) + }) + }) + + }, func(ctx *context.Context) { + ctx.Data["PageIsSettings"] = true + ctx.Data["LFSStartServer"] = setting.LFS.StartServer + }) + }, reqSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoAdmin, context.RepoRef()) + + m.Post("/{username}/{reponame}/action/{action}", reqSignIn, context.RepoAssignment, context.UnitTypes(), repo.Action) + + // Grouping for those endpoints not requiring authentication + m.Group("/{username}/{reponame}", func() { + m.Group("/milestone", func() { + m.Get("/{id}", repo.MilestoneIssuesAndPulls) + }, reqRepoIssuesOrPullsReader, context.RepoRef()) + m.Combo("/compare/*", repo.MustBeNotEmpty, reqRepoCodeReader, repo.SetEditorconfigIfExists). + Get(ignSignIn, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff). + Post(reqSignIn, context.RepoMustNotBeArchived(), reqRepoPullsReader, repo.MustAllowPulls, bindIgnErr(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost) + }, context.RepoAssignment, context.UnitTypes()) + + // Grouping for those endpoints that do require authentication + m.Group("/{username}/{reponame}", func() { + m.Group("/issues", func() { + m.Group("/new", func() { + m.Combo("").Get(context.RepoRef(), repo.NewIssue). + Post(bindIgnErr(forms.CreateIssueForm{}), repo.NewIssuePost) + m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) + }) + }, context.RepoMustNotBeArchived(), reqRepoIssueReader) + // FIXME: should use different URLs but mostly same logic for comments of issue and pull request. + // So they can apply their own enable/disable logic on routers. + m.Group("/issues", func() { + m.Group("/{index}", func() { + m.Post("/title", repo.UpdateIssueTitle) + m.Post("/content", repo.UpdateIssueContent) + m.Post("/watch", repo.IssueWatch) + m.Post("/ref", repo.UpdateIssueRef) + m.Group("/dependency", func() { + m.Post("/add", repo.AddDependency) + m.Post("/delete", repo.RemoveDependency) + }) + m.Combo("/comments").Post(repo.MustAllowUserComment, bindIgnErr(forms.CreateCommentForm{}), repo.NewComment) + m.Group("/times", func() { + m.Post("/add", bindIgnErr(forms.AddTimeManuallyForm{}), repo.AddTimeManually) + m.Post("/{timeid}/delete", repo.DeleteTime) + m.Group("/stopwatch", func() { + m.Post("/toggle", repo.IssueStopwatch) + m.Post("/cancel", repo.CancelStopwatch) + }) + }) + m.Post("/reactions/{action}", bindIgnErr(forms.ReactionForm{}), repo.ChangeIssueReaction) + m.Post("/lock", reqRepoIssueWriter, bindIgnErr(forms.IssueLockForm{}), repo.LockIssue) + m.Post("/unlock", reqRepoIssueWriter, repo.UnlockIssue) + }, context.RepoMustNotBeArchived()) + m.Group("/{index}", func() { + m.Get("/attachments", repo.GetIssueAttachments) + m.Get("/attachments/{uuid}", repo.GetAttachment) + }) + + m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) + m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) + m.Post("/projects", reqRepoIssuesOrPullsWriter, repo.UpdateIssueProject) + m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) + m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) + m.Post("/dismiss_review", reqRepoAdmin, bindIgnErr(forms.DismissReviewForm{}), repo.DismissReview) + m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) + m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation) + m.Post("/attachments", repo.UploadIssueAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Post("", repo.UpdateCommentContent) + m.Post("/delete", repo.DeleteComment) + m.Post("/reactions/{action}", bindIgnErr(forms.ReactionForm{}), repo.ChangeCommentReaction) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Get("/attachments", repo.GetCommentAttachments) + }) + m.Group("/labels", func() { + m.Post("/new", bindIgnErr(forms.CreateLabelForm{}), repo.NewLabel) + m.Post("/edit", bindIgnErr(forms.CreateLabelForm{}), repo.UpdateLabel) + m.Post("/delete", repo.DeleteLabel) + m.Post("/initialize", bindIgnErr(forms.InitializeLabelsForm{}), repo.InitializeLabels) + }, context.RepoMustNotBeArchived(), reqRepoIssuesOrPullsWriter, context.RepoRef()) + m.Group("/milestones", func() { + m.Combo("/new").Get(repo.NewMilestone). + Post(bindIgnErr(forms.CreateMilestoneForm{}), repo.NewMilestonePost) + m.Get("/{id}/edit", repo.EditMilestone) + m.Post("/{id}/edit", bindIgnErr(forms.CreateMilestoneForm{}), repo.EditMilestonePost) + m.Post("/{id}/{action}", repo.ChangeMilestoneStatus) + m.Post("/delete", repo.DeleteMilestone) + }, context.RepoMustNotBeArchived(), reqRepoIssuesOrPullsWriter, context.RepoRef()) + m.Group("/pull", func() { + m.Post("/{index}/target_branch", repo.UpdatePullRequestTarget) + }, context.RepoMustNotBeArchived()) + + m.Group("", func() { + m.Group("", func() { + m.Combo("/_edit/*").Get(repo.EditFile). + Post(bindIgnErr(forms.EditRepoFileForm{}), repo.EditFilePost) + m.Combo("/_new/*").Get(repo.NewFile). + Post(bindIgnErr(forms.EditRepoFileForm{}), repo.NewFilePost) + m.Post("/_preview/*", bindIgnErr(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost) + m.Combo("/_delete/*").Get(repo.DeleteFile). + Post(bindIgnErr(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) + m.Combo("/_upload/*", repo.MustBeAbleToUpload). + Get(repo.UploadFile). + Post(bindIgnErr(forms.UploadRepoFileForm{}), repo.UploadFilePost) + }, context.RepoRefByType(context.RepoRefBranch), repo.MustBeEditable) + m.Group("", func() { + m.Post("/upload-file", repo.UploadFileToServer) + m.Post("/upload-remove", bindIgnErr(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) + }, context.RepoRef(), repo.MustBeEditable, repo.MustBeAbleToUpload) + }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty) + + m.Group("/branches", func() { + m.Group("/_new", func() { + m.Post("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.CreateBranch) + m.Post("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.CreateBranch) + m.Post("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.CreateBranch) + }, bindIgnErr(forms.NewBranchForm{})) + m.Post("/delete", repo.DeleteBranchPost) + m.Post("/restore", repo.RestoreBranchPost) + }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty) + + }, reqSignIn, context.RepoAssignment, context.UnitTypes()) + + // Releases + m.Group("/{username}/{reponame}", func() { + m.Get("/tags", repo.TagsList, repo.MustBeNotEmpty, + reqRepoCodeReader, context.RepoRefByType(context.RepoRefTag)) + m.Group("/releases", func() { + m.Get("/", repo.Releases) + m.Get("/tag/*", repo.SingleRelease) + m.Get("/latest", repo.LatestRelease) + }, repo.MustBeNotEmpty, reqRepoReleaseReader, context.RepoRefByType(context.RepoRefTag, true)) + m.Get("/releases/attachments/{uuid}", repo.GetAttachment, repo.MustBeNotEmpty, reqRepoReleaseReader) + m.Group("/releases", func() { + m.Get("/new", repo.NewRelease) + m.Post("/new", bindIgnErr(forms.NewReleaseForm{}), repo.NewReleasePost) + m.Post("/delete", repo.DeleteRelease) + m.Post("/attachments", repo.UploadReleaseAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) + }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, context.RepoRef()) + m.Post("/tags/delete", repo.DeleteTag, reqSignIn, + repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoCodeWriter, context.RepoRef()) + m.Group("/releases", func() { + m.Get("/edit/*", repo.EditRelease) + m.Post("/edit/*", bindIgnErr(forms.EditReleaseForm{}), repo.EditReleasePost) + }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, func(ctx *context.Context) { + var err error + ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.ServerError("GetBranchCommit", err) + return + } + ctx.Repo.CommitsCount, err = ctx.Repo.GetCommitsCount() + if err != nil { + ctx.ServerError("GetCommitsCount", err) + return + } + ctx.Data["CommitsCount"] = ctx.Repo.CommitsCount + }) + m.Get("/attachments/{uuid}", repo.GetAttachment) + }, ignSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoReleaseReader) + + m.Group("/{username}/{reponame}", func() { + m.Post("/topics", repo.TopicsPost) + }, context.RepoAssignment, context.RepoMustNotBeArchived(), reqRepoAdmin) + + m.Group("/{username}/{reponame}", func() { + m.Group("", func() { + m.Get("/{type:issues|pulls}", repo.Issues) + m.Get("/{type:issues|pulls}/{index}", repo.ViewIssue) + m.Get("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels) + m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) + }, context.RepoRef()) + + m.Group("/projects", func() { + m.Get("", repo.Projects) + m.Get("/{id}", repo.ViewProject) + m.Group("", func() { + m.Get("/new", repo.NewProject) + m.Post("/new", bindIgnErr(forms.CreateProjectForm{}), repo.NewProjectPost) + m.Group("/{id}", func() { + m.Post("", bindIgnErr(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost) + m.Post("/delete", repo.DeleteProject) + + m.Get("/edit", repo.EditProject) + m.Post("/edit", bindIgnErr(forms.CreateProjectForm{}), repo.EditProjectPost) + m.Post("/{action:open|close}", repo.ChangeProjectStatus) + + m.Group("/{boardID}", func() { + m.Put("", bindIgnErr(forms.EditProjectBoardForm{}), repo.EditProjectBoard) + m.Delete("", repo.DeleteProjectBoard) + m.Post("/default", repo.SetDefaultProjectBoard) + + m.Post("/{index}", repo.MoveIssueAcrossBoards) + }) + }) + }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) + }, reqRepoProjectsReader, repo.MustEnableProjects) + + m.Group("/wiki", func() { + m.Get("/", repo.Wiki) + m.Get("/{page}", repo.Wiki) + m.Get("/_pages", repo.WikiPages) + m.Get("/{page}/_revision", repo.WikiRevision) + m.Get("/commit/{sha:[a-f0-9]{7,40}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/commit/{sha:[a-f0-9]{7,40}}.{:patch|diff}", repo.RawDiff) + + m.Group("", func() { + m.Combo("/_new").Get(repo.NewWiki). + Post(bindIgnErr(forms.NewWikiForm{}), repo.NewWikiPost) + m.Combo("/{page}/_edit").Get(repo.EditWiki). + Post(bindIgnErr(forms.NewWikiForm{}), repo.EditWikiPost) + m.Post("/{page}/delete", repo.DeleteWikiPagePost) + }, context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter) + }, repo.MustEnableWiki, context.RepoRef(), func(ctx *context.Context) { + ctx.Data["PageIsWiki"] = true + }) + + m.Group("/wiki", func() { + m.Get("/raw/*", repo.WikiRaw) + }, repo.MustEnableWiki) + + m.Group("/activity", func() { + m.Get("", repo.Activity) + m.Get("/{period}", repo.Activity) + }, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(models.UnitTypePullRequests, models.UnitTypeIssues, models.UnitTypeReleases)) + + m.Group("/activity_author_data", func() { + m.Get("", repo.ActivityAuthors) + m.Get("/{period}", repo.ActivityAuthors) + }, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(models.UnitTypeCode)) + + m.Group("/archive", func() { + m.Get("/*", common.Download) + m.Post("/*", repo.InitiateDownload) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/branches", func() { + m.Get("", repo.Branches) + }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) + + m.Group("/blob_excerpt", func() { + m.Get("/{sha}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.ExcerptBlob) + }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) + + m.Group("/pulls/{index}", func() { + m.Get(".diff", repo.DownloadPullDiff) + m.Get(".patch", repo.DownloadPullPatch) + m.Get("/commits", context.RepoRef(), repo.ViewPullCommits) + m.Post("/merge", context.RepoMustNotBeArchived(), bindIgnErr(forms.MergePullRequestForm{}), repo.MergePullRequest) + m.Post("/update", repo.UpdatePullRequest) + m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest) + m.Group("/files", func() { + m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.ViewPullFiles) + m.Group("/reviews", func() { + m.Get("/new_comment", repo.RenderNewCodeCommentForm) + m.Post("/comments", bindIgnErr(forms.CodeCommentForm{}), repo.CreateCodeComment) + m.Post("/submit", bindIgnErr(forms.SubmitReviewForm{}), repo.SubmitReview) + }, context.RepoMustNotBeArchived()) + }) + }, repo.MustAllowPulls) + + m.Group("/media", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.SingleDownloadOrLFS) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.SingleDownloadOrLFS) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.SingleDownloadOrLFS) + m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.DownloadByIDOrLFS) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownloadOrLFS) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/raw", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.SingleDownload) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.SingleDownload) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.SingleDownload) + m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.DownloadByID) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownload) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/commits", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RefCommits) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RefCommits) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.RefCommits) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.RefCommits) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/blame", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RefBlame) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RefBlame) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.RefBlame) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("", func() { + m.Get("/graph", repo.Graph) + m.Get("/commit/{sha:([a-f0-9]{7,40})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) + + m.Group("/src", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.Home) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.Home) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.Home) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.Home) + }, repo.SetEditorconfigIfExists) + + m.Group("", func() { + m.Get("/forks", repo.Forks) + }, context.RepoRef(), reqRepoCodeReader) + m.Get("/commit/{sha:([a-f0-9]{7,40})}.{ext:patch|diff}", + repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + m.Group("/{username}/{reponame}", func() { + m.Get("/stars", repo.Stars) + m.Get("/watchers", repo.Watchers) + m.Get("/search", reqRepoCodeReader, repo.Search) + }, ignSignIn, context.RepoAssignment, context.RepoRef(), context.UnitTypes()) + + m.Group("/{username}", func() { + m.Group("/{reponame}", func() { + m.Get("", repo.SetEditorconfigIfExists, repo.Home) + }, ignSignIn, context.RepoAssignment, context.RepoRef(), context.UnitTypes()) + + m.Group("/{reponame}", func() { + m.Group("/info/lfs", func() { + m.Post("/objects/batch", lfs.CheckAcceptMediaType, lfs.BatchHandler) + m.Put("/objects/{oid}/{size}", lfs.UploadHandler) + m.Get("/objects/{oid}/{filename}", lfs.DownloadHandler) + m.Get("/objects/{oid}", lfs.DownloadHandler) + m.Post("/verify", lfs.CheckAcceptMediaType, lfs.VerifyHandler) + m.Group("/locks", func() { + m.Get("/", lfs.GetListLockHandler) + m.Post("/", lfs.PostLockHandler) + m.Post("/verify", lfs.VerifyLockHandler) + m.Post("/{lid}/unlock", lfs.UnLockHandler) + }, lfs.CheckAcceptMediaType) + m.Any("/*", func(ctx *context.Context) { + ctx.NotFound("", nil) + }) + }, ignSignInAndCsrf, lfsServerEnabled) + + m.Group("", func() { + m.Post("/git-upload-pack", repo.ServiceUploadPack) + m.Post("/git-receive-pack", repo.ServiceReceivePack) + m.Get("/info/refs", repo.GetInfoRefs) + m.Get("/HEAD", repo.GetTextFile("HEAD")) + m.Get("/objects/info/alternates", repo.GetTextFile("objects/info/alternates")) + m.Get("/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates")) + m.Get("/objects/info/packs", repo.GetInfoPacks) + m.Get("/objects/info/{file:[^/]*}", repo.GetTextFile("")) + m.Get("/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38}}", repo.GetLooseObject) + m.Get("/objects/pack/pack-{file:[0-9a-f]{40}}.pack", repo.GetPackFile) + m.Get("/objects/pack/pack-{file:[0-9a-f]{40}}.idx", repo.GetIdxFile) + }, ignSignInAndCsrf) + + m.Head("/tasks/trigger", repo.TriggerTask) + }) + }) + // ***** END: Repository ***** + + m.Group("/notifications", func() { + m.Get("", user.Notifications) + m.Post("/status", user.NotificationStatusPost) + m.Post("/purge", user.NotificationPurgePost) + }, reqSignIn) + + if setting.API.EnableSwagger { + m.Get("/swagger.v1.json", SwaggerV1Json) + } +} |