diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /routers/web | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
175 files changed, 42788 insertions, 0 deletions
diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go new file mode 100644 index 0000000..067203b --- /dev/null +++ b/routers/web/admin/admin.go @@ -0,0 +1,254 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + "reflect" + "runtime" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/updatechecker" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/cron" + "code.gitea.io/gitea/services/forms" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplDashboard base.TplName = "admin/dashboard" + tplSystemStatus base.TplName = "admin/system_status" + tplSelfCheck base.TplName = "admin/self_check" + tplCron base.TplName = "admin/cron" + tplQueue base.TplName = "admin/queue" + tplStacktrace base.TplName = "admin/stacktrace" + tplQueueManage base.TplName = "admin/queue_manage" + tplStats base.TplName = "admin/stats" +) + +var sysStatus struct { + StartTime string + NumGoroutine int + + // General statistics. + MemAllocated string // bytes allocated and still in use + MemTotal string // bytes allocated (even if freed) + MemSys string // bytes obtained from system (sum of XxxSys below) + Lookups uint64 // number of pointer lookups + MemMallocs uint64 // number of mallocs + MemFrees uint64 // number of frees + + // Main allocation heap statistics. + HeapAlloc string // bytes allocated and still in use + HeapSys string // bytes obtained from system + HeapIdle string // bytes in idle spans + HeapInuse string // bytes in non-idle span + HeapReleased string // bytes released to the OS + HeapObjects uint64 // total number of allocated objects + + // Low-level fixed-size structure allocator statistics. + // Inuse is bytes used now. + // Sys is bytes obtained from system. + StackInuse string // bootstrap stacks + StackSys string + MSpanInuse string // mspan structures + MSpanSys string + MCacheInuse string // mcache structures + MCacheSys string + BuckHashSys string // profiling bucket hash table + GCSys string // GC metadata + OtherSys string // other system allocations + + // Garbage collector statistics. + NextGC string // next run in HeapAlloc time (bytes) + LastGCTime string // last run time + PauseTotalNs string + PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256] + NumGC uint32 +} + +func updateSystemStatus() { + sysStatus.StartTime = setting.AppStartTime.Format(time.RFC3339) + + m := new(runtime.MemStats) + runtime.ReadMemStats(m) + sysStatus.NumGoroutine = runtime.NumGoroutine() + + sysStatus.MemAllocated = base.FileSize(int64(m.Alloc)) + sysStatus.MemTotal = base.FileSize(int64(m.TotalAlloc)) + sysStatus.MemSys = base.FileSize(int64(m.Sys)) + sysStatus.Lookups = m.Lookups + sysStatus.MemMallocs = m.Mallocs + sysStatus.MemFrees = m.Frees + + sysStatus.HeapAlloc = base.FileSize(int64(m.HeapAlloc)) + sysStatus.HeapSys = base.FileSize(int64(m.HeapSys)) + sysStatus.HeapIdle = base.FileSize(int64(m.HeapIdle)) + sysStatus.HeapInuse = base.FileSize(int64(m.HeapInuse)) + sysStatus.HeapReleased = base.FileSize(int64(m.HeapReleased)) + sysStatus.HeapObjects = m.HeapObjects + + sysStatus.StackInuse = base.FileSize(int64(m.StackInuse)) + sysStatus.StackSys = base.FileSize(int64(m.StackSys)) + sysStatus.MSpanInuse = base.FileSize(int64(m.MSpanInuse)) + sysStatus.MSpanSys = base.FileSize(int64(m.MSpanSys)) + sysStatus.MCacheInuse = base.FileSize(int64(m.MCacheInuse)) + sysStatus.MCacheSys = base.FileSize(int64(m.MCacheSys)) + sysStatus.BuckHashSys = base.FileSize(int64(m.BuckHashSys)) + sysStatus.GCSys = base.FileSize(int64(m.GCSys)) + sysStatus.OtherSys = base.FileSize(int64(m.OtherSys)) + + sysStatus.NextGC = base.FileSize(int64(m.NextGC)) + sysStatus.LastGCTime = time.Unix(0, int64(m.LastGC)).Format(time.RFC3339) + sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000) + sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000) + sysStatus.NumGC = m.NumGC +} + +func prepareDeprecatedWarningsAlert(ctx *context.Context) { + if len(setting.DeprecatedWarnings) > 0 { + content := setting.DeprecatedWarnings[0] + if len(setting.DeprecatedWarnings) > 1 { + content += fmt.Sprintf(" (and %d more)", len(setting.DeprecatedWarnings)-1) + } + ctx.Flash.Error(content, true) + } +} + +// Dashboard show admin panel dashboard +func Dashboard(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.dashboard") + ctx.Data["PageIsAdminDashboard"] = true + ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate(ctx) + ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion(ctx) + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + ctx.Data["SSH"] = setting.SSH + prepareDeprecatedWarningsAlert(ctx) + ctx.HTML(http.StatusOK, tplDashboard) +} + +func SystemStatus(ctx *context.Context) { + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + ctx.HTML(http.StatusOK, tplSystemStatus) +} + +// DashboardPost run an admin operation +func DashboardPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminDashboardForm) + ctx.Data["Title"] = ctx.Tr("admin.dashboard") + ctx.Data["PageIsAdminDashboard"] = true + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + + // Run operation. + if form.Op != "" { + switch form.Op { + case "sync_repo_branches": + go func() { + if err := repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext()); err != nil { + log.Error("AddAllRepoBranchesToSyncQueue: %v: %v", ctx.Doer.ID, err) + } + }() + ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_branch.started")) + case "sync_repo_tags": + go func() { + if err := release_service.AddAllRepoTagsToSyncQueue(graceful.GetManager().ShutdownContext()); err != nil { + log.Error("AddAllRepoTagsToSyncQueue: %v: %v", ctx.Doer.ID, err) + } + }() + ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_tag.started")) + default: + task := cron.GetTask(form.Op) + if task != nil { + go task.RunWithUser(ctx.Doer, nil) + ctx.Flash.Success(ctx.Tr("admin.dashboard.task.started", ctx.Tr("admin.dashboard."+form.Op))) + } else { + ctx.Flash.Error(ctx.Tr("admin.dashboard.task.unknown", form.Op)) + } + } + } + if form.From == "monitor" { + ctx.Redirect(setting.AppSubURL + "/admin/monitor/cron") + } else { + ctx.Redirect(setting.AppSubURL + "/admin") + } +} + +func SelfCheck(ctx *context.Context) { + ctx.Data["PageIsAdminSelfCheck"] = true + r, err := db.CheckCollationsDefaultEngine() + if err != nil { + ctx.Flash.Error(fmt.Sprintf("CheckCollationsDefaultEngine: %v", err), true) + } + + if r != nil { + ctx.Data["DatabaseType"] = setting.Database.Type + ctx.Data["DatabaseCheckResult"] = r + hasProblem := false + if !r.CollationEquals(r.DatabaseCollation, r.ExpectedCollation) { + ctx.Data["DatabaseCheckCollationMismatch"] = true + hasProblem = true + } + if !r.IsCollationCaseSensitive(r.DatabaseCollation) { + ctx.Data["DatabaseCheckCollationCaseInsensitive"] = true + hasProblem = true + } + ctx.Data["DatabaseCheckInconsistentCollationColumns"] = r.InconsistentCollationColumns + hasProblem = hasProblem || len(r.InconsistentCollationColumns) > 0 + + ctx.Data["DatabaseCheckHasProblems"] = hasProblem + } + + elapsed, err := cache.Test() + if err != nil { + ctx.Data["CacheError"] = err + } else if elapsed > cache.SlowCacheThreshold { + ctx.Data["CacheSlow"] = fmt.Sprint(elapsed) + } + + ctx.HTML(http.StatusOK, tplSelfCheck) +} + +func CronTasks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.cron") + ctx.Data["PageIsAdminMonitorCron"] = true + ctx.Data["Entries"] = cron.ListTasks() + ctx.HTML(http.StatusOK, tplCron) +} + +func MonitorStats(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.stats") + ctx.Data["PageIsAdminMonitorStats"] = true + modelStats := activities_model.GetStatistic(ctx).Counter + stats := map[string]any{} + + // To avoid manually converting the values of the stats struct to an map, + // and to avoid using JSON to do this for us (JSON encoder converts numbers to + // scientific notation). Use reflect to convert the struct to an map. + rv := reflect.ValueOf(modelStats) + for i := 0; i < rv.NumField(); i++ { + field := rv.Field(i) + // Preserve old behavior, do not show arrays that are empty. + if field.Kind() == reflect.Slice && field.Len() == 0 { + continue + } + stats[rv.Type().Field(i).Name] = field.Interface() + } + + ctx.Data["Stats"] = stats + ctx.HTML(http.StatusOK, tplStats) +} diff --git a/routers/web/admin/admin_test.go b/routers/web/admin/admin_test.go new file mode 100644 index 0000000..3518869 --- /dev/null +++ b/routers/web/admin/admin_test.go @@ -0,0 +1,117 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestShadowPassword(t *testing.T) { + kases := []struct { + Provider string + CfgItem string + Result string + }{ + { + Provider: "redis", + CfgItem: "network=tcp,addr=:6379,password=gitea,db=0,pool_size=100,idle_timeout=180", + Result: "network=tcp,addr=:6379,password=******,db=0,pool_size=100,idle_timeout=180", + }, + { + Provider: "mysql", + CfgItem: "root:@tcp(localhost:3306)/gitea?charset=utf8", + Result: "root:******@tcp(localhost:3306)/gitea?charset=utf8", + }, + { + Provider: "mysql", + CfgItem: "/gitea?charset=utf8", + Result: "/gitea?charset=utf8", + }, + { + Provider: "mysql", + CfgItem: "user:mypassword@/dbname", + Result: "user:******@/dbname", + }, + { + Provider: "postgres", + CfgItem: "user=pqgotest dbname=pqgotest sslmode=verify-full", + Result: "user=pqgotest dbname=pqgotest sslmode=verify-full", + }, + { + Provider: "postgres", + CfgItem: "user=pqgotest password= dbname=pqgotest sslmode=verify-full", + Result: "user=pqgotest password=****** dbname=pqgotest sslmode=verify-full", + }, + { + Provider: "postgres", + CfgItem: "postgres://user:pass@hostname/dbname", + Result: "postgres://user:******@hostname/dbname", + }, + { + Provider: "couchbase", + CfgItem: "http://dev-couchbase.example.com:8091/", + Result: "http://dev-couchbase.example.com:8091/", + }, + { + Provider: "couchbase", + CfgItem: "http://user:the_password@dev-couchbase.example.com:8091/", + Result: "http://user:******@dev-couchbase.example.com:8091/", + }, + } + + for _, k := range kases { + assert.EqualValues(t, k.Result, shadowPassword(k.Provider, k.CfgItem)) + } +} + +func TestMonitorStats(t *testing.T) { + unittest.PrepareTestEnv(t) + + t.Run("Normal", func(t *testing.T) { + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByLabel, false)() + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByRepository, false)() + + ctx, _ := contexttest.MockContext(t, "admin/stats") + MonitorStats(ctx) + + // Test some of the stats manually. + mappedStats := ctx.Data["Stats"].(map[string]any) + stats := activities_model.GetStatistic(ctx).Counter + + assert.EqualValues(t, stats.Comment, mappedStats["Comment"]) + assert.EqualValues(t, stats.Issue, mappedStats["Issue"]) + assert.EqualValues(t, stats.User, mappedStats["User"]) + assert.EqualValues(t, stats.Milestone, mappedStats["Milestone"]) + + // Ensure that these aren't set. + assert.Empty(t, stats.IssueByLabel) + assert.Empty(t, stats.IssueByRepository) + assert.Nil(t, mappedStats["IssueByLabel"]) + assert.Nil(t, mappedStats["IssueByRepository"]) + }) + + t.Run("IssueByX", func(t *testing.T) { + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByLabel, true)() + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByRepository, true)() + + ctx, _ := contexttest.MockContext(t, "admin/stats") + MonitorStats(ctx) + + mappedStats := ctx.Data["Stats"].(map[string]any) + stats := activities_model.GetStatistic(ctx).Counter + + assert.NotEmpty(t, stats.IssueByLabel) + assert.NotEmpty(t, stats.IssueByRepository) + assert.EqualValues(t, stats.IssueByLabel, mappedStats["IssueByLabel"]) + assert.EqualValues(t, stats.IssueByRepository, mappedStats["IssueByRepository"]) + }) +} diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go new file mode 100644 index 0000000..8583398 --- /dev/null +++ b/routers/web/admin/applications.go @@ -0,0 +1,90 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" +) + +var ( + tplSettingsApplications base.TplName = "admin/applications/list" + tplSettingsOauth2ApplicationEdit base.TplName = "admin/applications/oauth2_edit" +) + +func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers { + return &user_setting.OAuth2CommonHandlers{ + OwnerID: 0, + BasePathList: fmt.Sprintf("%s/admin/applications", setting.AppSubURL), + BasePathEditPrefix: fmt.Sprintf("%s/admin/applications/oauth2", setting.AppSubURL), + TplAppEdit: tplSettingsOauth2ApplicationEdit, + } +} + +// Applications render org applications page (for org, at the moment, there are only OAuth2 applications) +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsAdminApplications"] = true + + apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{ + IsGlobal: true, + }) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Applications"] = apps + ctx.Data["BuiltinApplications"] = auth.BuiltinApplications() + ctx.HTML(http.StatusOK, tplSettingsApplications) +} + +// ApplicationsPost response for adding an oauth2 application +func ApplicationsPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.AddApp(ctx) +} + +// EditApplication displays the given application +func EditApplication(ctx *context.Context) { + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.EditShow(ctx) +} + +// EditApplicationPost response for editing oauth2 application +func EditApplicationPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.EditSave(ctx) +} + +// ApplicationsRegenerateSecret handles the post request for regenerating the secret +func ApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.RegenerateSecret(ctx) +} + +// DeleteApplication deletes the given oauth2 application +func DeleteApplication(ctx *context.Context) { + oa := newOAuth2CommonHandlers() + oa.DeleteApp(ctx) +} + +// TODO: revokes the grant with the given id diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go new file mode 100644 index 0000000..799b7e8 --- /dev/null +++ b/routers/web/admin/auths.go @@ -0,0 +1,465 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/auth/pam" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/ldap" + "code.gitea.io/gitea/services/auth/source/oauth2" + pam_service "code.gitea.io/gitea/services/auth/source/pam" + "code.gitea.io/gitea/services/auth/source/smtp" + "code.gitea.io/gitea/services/auth/source/sspi" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + + "xorm.io/xorm/convert" +) + +const ( + tplAuths base.TplName = "admin/auth/list" + tplAuthNew base.TplName = "admin/auth/new" + tplAuthEdit base.TplName = "admin/auth/edit" +) + +var ( + separatorAntiPattern = regexp.MustCompile(`[^\w-\.]`) + langCodePattern = regexp.MustCompile(`^[a-z]{2}-[A-Z]{2}$`) +) + +// Authentications show authentication config page +func Authentications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.authentication") + ctx.Data["PageIsAdminAuthentications"] = true + + var err error + ctx.Data["Sources"], ctx.Data["Total"], err = db.FindAndCount[auth.Source](ctx, auth.FindSourcesOptions{}) + if err != nil { + ctx.ServerError("auth.Sources", err) + return + } + + ctx.HTML(http.StatusOK, tplAuths) +} + +type dropdownItem struct { + Name string + Type any +} + +var ( + authSources = func() []dropdownItem { + items := []dropdownItem{ + {auth.LDAP.String(), auth.LDAP}, + {auth.DLDAP.String(), auth.DLDAP}, + {auth.SMTP.String(), auth.SMTP}, + {auth.OAuth2.String(), auth.OAuth2}, + {auth.SSPI.String(), auth.SSPI}, + } + if pam.Supported { + items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM}) + } + return items + }() + + securityProtocols = []dropdownItem{ + {ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted}, + {ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, + {ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, + } +) + +// NewAuthSource render adding a new auth source page +func NewAuthSource(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.auths.new") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["type"] = auth.LDAP.Int() + ctx.Data["CurrentTypeName"] = auth.Names[auth.LDAP] + ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted] + ctx.Data["smtp_auth"] = "PLAIN" + ctx.Data["is_active"] = true + ctx.Data["is_sync_enabled"] = true + ctx.Data["AuthSources"] = authSources + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + ctx.Data["SSPIAutoCreateUsers"] = true + ctx.Data["SSPIAutoActivateUsers"] = true + ctx.Data["SSPIStripDomainNames"] = true + ctx.Data["SSPISeparatorReplacement"] = "_" + ctx.Data["SSPIDefaultLanguage"] = "" + + // only the first as default + ctx.Data["oauth2_provider"] = oauth2providers[0].Name() + + ctx.HTML(http.StatusOK, tplAuthNew) +} + +func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source { + var pageSize uint32 + if form.UsePagedSearch { + pageSize = uint32(form.SearchPageSize) + } + return &ldap.Source{ + Name: form.Name, + Host: form.Host, + Port: form.Port, + SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol), + SkipVerify: form.SkipVerify, + BindDN: form.BindDN, + UserDN: form.UserDN, + BindPassword: form.BindPassword, + UserBase: form.UserBase, + DefaultDomainName: form.DefaultDomainName, + AttributeUsername: form.AttributeUsername, + AttributeName: form.AttributeName, + AttributeSurname: form.AttributeSurname, + AttributeMail: form.AttributeMail, + AttributesInBind: form.AttributesInBind, + AttributeSSHPublicKey: form.AttributeSSHPublicKey, + AttributeAvatar: form.AttributeAvatar, + SearchPageSize: pageSize, + Filter: form.Filter, + GroupsEnabled: form.GroupsEnabled, + GroupDN: form.GroupDN, + GroupFilter: form.GroupFilter, + GroupMemberUID: form.GroupMemberUID, + GroupTeamMap: form.GroupTeamMap, + GroupTeamMapRemoval: form.GroupTeamMapRemoval, + UserUID: form.UserUID, + AdminFilter: form.AdminFilter, + RestrictedFilter: form.RestrictedFilter, + AllowDeactivateAll: form.AllowDeactivateAll, + Enabled: true, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } +} + +func parseSMTPConfig(form forms.AuthenticationForm) *smtp.Source { + return &smtp.Source{ + Auth: form.SMTPAuth, + Host: form.SMTPHost, + Port: form.SMTPPort, + AllowedDomains: form.AllowedDomains, + ForceSMTPS: form.ForceSMTPS, + SkipVerify: form.SkipVerify, + HeloHostname: form.HeloHostname, + DisableHelo: form.DisableHelo, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } +} + +func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { + var customURLMapping *oauth2.CustomURLMapping + if form.Oauth2UseCustomURL { + customURLMapping = &oauth2.CustomURLMapping{ + TokenURL: form.Oauth2TokenURL, + AuthURL: form.Oauth2AuthURL, + ProfileURL: form.Oauth2ProfileURL, + EmailURL: form.Oauth2EmailURL, + Tenant: form.Oauth2Tenant, + } + } else { + customURLMapping = nil + } + var scopes []string + for _, s := range strings.Split(form.Oauth2Scopes, ",") { + s = strings.TrimSpace(s) + if s != "" { + scopes = append(scopes, s) + } + } + + return &oauth2.Source{ + Provider: form.Oauth2Provider, + ClientID: form.Oauth2Key, + ClientSecret: form.Oauth2Secret, + OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL, + CustomURLMapping: customURLMapping, + IconURL: form.Oauth2IconURL, + Scopes: scopes, + RequiredClaimName: form.Oauth2RequiredClaimName, + RequiredClaimValue: form.Oauth2RequiredClaimValue, + SkipLocalTwoFA: form.SkipLocalTwoFA, + GroupClaimName: form.Oauth2GroupClaimName, + RestrictedGroup: form.Oauth2RestrictedGroup, + AdminGroup: form.Oauth2AdminGroup, + GroupTeamMap: form.Oauth2GroupTeamMap, + GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, + } +} + +func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi.Source, error) { + if util.IsEmptyString(form.SSPISeparatorReplacement) { + ctx.Data["Err_SSPISeparatorReplacement"] = true + return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.require_error")) + } + if separatorAntiPattern.MatchString(form.SSPISeparatorReplacement) { + ctx.Data["Err_SSPISeparatorReplacement"] = true + return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.alpha_dash_dot_error")) + } + + if form.SSPIDefaultLanguage != "" && !langCodePattern.MatchString(form.SSPIDefaultLanguage) { + ctx.Data["Err_SSPIDefaultLanguage"] = true + return nil, errors.New(ctx.Locale.TrString("form.lang_select_error")) + } + + return &sspi.Source{ + AutoCreateUsers: form.SSPIAutoCreateUsers, + AutoActivateUsers: form.SSPIAutoActivateUsers, + StripDomainNames: form.SSPIStripDomainNames, + SeparatorReplacement: form.SSPISeparatorReplacement, + DefaultLanguage: form.SSPIDefaultLanguage, + }, nil +} + +// NewAuthSourcePost response for adding an auth source +func NewAuthSourcePost(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AuthenticationForm) + ctx.Data["Title"] = ctx.Tr("admin.auths.new") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["CurrentTypeName"] = auth.Type(form.Type).String() + ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)] + ctx.Data["AuthSources"] = authSources + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + ctx.Data["SSPIAutoCreateUsers"] = true + ctx.Data["SSPIAutoActivateUsers"] = true + ctx.Data["SSPIStripDomainNames"] = true + ctx.Data["SSPISeparatorReplacement"] = "_" + ctx.Data["SSPIDefaultLanguage"] = "" + + hasTLS := false + var config convert.Conversion + switch auth.Type(form.Type) { + case auth.LDAP, auth.DLDAP: + config = parseLDAPConfig(form) + hasTLS = ldap.SecurityProtocol(form.SecurityProtocol) > ldap.SecurityProtocolUnencrypted + case auth.SMTP: + config = parseSMTPConfig(form) + hasTLS = true + case auth.PAM: + config = &pam_service.Source{ + ServiceName: form.PAMServiceName, + EmailDomain: form.PAMEmailDomain, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } + case auth.OAuth2: + config = parseOAuth2Config(form) + oauth2Config := config.(*oauth2.Source) + if oauth2Config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { + ctx.Data["Err_DiscoveryURL"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthNew, form) + return + } + } + case auth.SSPI: + var err error + config, err = parseSSPIConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthNew, form) + return + } + existing, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{LoginType: auth.SSPI}) + if err != nil || len(existing) > 0 { + ctx.Data["Err_Type"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) + return + } + default: + ctx.Error(http.StatusBadRequest) + return + } + ctx.Data["HasTLS"] = hasTLS + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplAuthNew) + return + } + + if err := auth.CreateSource(ctx, &auth.Source{ + Type: auth.Type(form.Type), + Name: form.Name, + IsActive: form.IsActive, + IsSyncEnabled: form.IsSyncEnabled, + Cfg: config, + }); err != nil { + if auth.IsErrSourceAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthNew, form) + } else if oauth2.IsErrOpenIDConnectInitialize(err) { + ctx.Data["Err_DiscoveryURL"] = true + unwrapped := err.(oauth2.ErrOpenIDConnectInitialize).Unwrap() + ctx.RenderWithErr(ctx.Tr("admin.auths.unable_to_initialize_openid", unwrapped), tplAuthNew, form) + } else { + ctx.ServerError("auth.CreateSource", err) + } + return + } + + log.Trace("Authentication created by admin(%s): %s", ctx.Doer.Name, form.Name) + + ctx.Flash.Success(ctx.Tr("admin.auths.new_success", form.Name)) + ctx.Redirect(setting.AppSubURL + "/admin/auths") +} + +// EditAuthSource render editing auth source page +func EditAuthSource(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.auths.edit") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return + } + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + + if source.IsOAuth2() { + type Named interface { + Name() string + } + + for _, provider := range oauth2providers { + if provider.Name() == source.Cfg.(Named).Name() { + ctx.Data["CurrentOAuth2Provider"] = provider + break + } + } + } + + ctx.HTML(http.StatusOK, tplAuthEdit) +} + +// EditAuthSourcePost response for editing auth source +func EditAuthSourcePost(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AuthenticationForm) + ctx.Data["Title"] = ctx.Tr("admin.auths.edit") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return + } + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplAuthEdit) + return + } + + var config convert.Conversion + switch auth.Type(form.Type) { + case auth.LDAP, auth.DLDAP: + config = parseLDAPConfig(form) + case auth.SMTP: + config = parseSMTPConfig(form) + case auth.PAM: + config = &pam_service.Source{ + ServiceName: form.PAMServiceName, + EmailDomain: form.PAMEmailDomain, + } + case auth.OAuth2: + config = parseOAuth2Config(form) + oauth2Config := config.(*oauth2.Source) + if oauth2Config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { + ctx.Data["Err_DiscoveryURL"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthEdit, form) + return + } + } + case auth.SSPI: + config, err = parseSSPIConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthEdit, form) + return + } + default: + ctx.Error(http.StatusBadRequest) + return + } + + source.Name = form.Name + source.IsActive = form.IsActive + source.IsSyncEnabled = form.IsSyncEnabled + source.Cfg = config + if err := auth.UpdateSource(ctx, source); err != nil { + if auth.IsErrSourceAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthEdit, form) + } else if oauth2.IsErrOpenIDConnectInitialize(err) { + ctx.Flash.Error(err.Error(), true) + ctx.Data["Err_DiscoveryURL"] = true + ctx.HTML(http.StatusOK, tplAuthEdit) + } else { + ctx.ServerError("UpdateSource", err) + } + return + } + log.Trace("Authentication changed by admin(%s): %d", ctx.Doer.Name, source.ID) + + ctx.Flash.Success(ctx.Tr("admin.auths.update_success")) + ctx.Redirect(setting.AppSubURL + "/admin/auths/" + strconv.FormatInt(form.ID, 10)) +} + +// DeleteAuthSource response for deleting an auth source +func DeleteAuthSource(ctx *context.Context) { + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return + } + + if err = auth_service.DeleteSource(ctx, source); err != nil { + if auth.IsErrSourceInUse(err) { + ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used")) + } else { + ctx.Flash.Error(fmt.Sprintf("auth_service.DeleteSource: %v", err)) + } + ctx.JSONRedirect(setting.AppSubURL + "/admin/auths/" + url.PathEscape(ctx.Params(":authid"))) + return + } + log.Trace("Authentication deleted by admin(%s): %d", ctx.Doer.Name, source.ID) + + ctx.Flash.Success(ctx.Tr("admin.auths.deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/admin/auths") +} diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go new file mode 100644 index 0000000..06d0ea6 --- /dev/null +++ b/routers/web/admin/config.go @@ -0,0 +1,255 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" + + "code.forgejo.org/go-chi/session" +) + +const ( + tplConfig base.TplName = "admin/config" + tplConfigSettings base.TplName = "admin/config_settings" +) + +// SendTestMail send test mail to confirm mail service is OK +func SendTestMail(ctx *context.Context) { + email := ctx.FormString("email") + // Send a test email to the user's email address and redirect back to Config + if err := mailer.SendTestMail(email); err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.test_mail_failed", email, err)) + } else { + ctx.Flash.Info(ctx.Tr("admin.config.test_mail_sent", email)) + } + + ctx.Redirect(setting.AppSubURL + "/admin/config") +} + +// TestCache test the cache settings +func TestCache(ctx *context.Context) { + elapsed, err := cache.Test() + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.cache_test_failed", err)) + } else { + if elapsed > cache.SlowCacheThreshold { + ctx.Flash.Warning(ctx.Tr("admin.config.cache_test_slow", elapsed)) + } else { + ctx.Flash.Info(ctx.Tr("admin.config.cache_test_succeeded", elapsed)) + } + } + + ctx.Redirect(setting.AppSubURL + "/admin/config") +} + +func shadowPasswordKV(cfgItem, splitter string) string { + fields := strings.Split(cfgItem, splitter) + for i := 0; i < len(fields); i++ { + if strings.HasPrefix(fields[i], "password=") { + fields[i] = "password=******" + break + } + } + return strings.Join(fields, splitter) +} + +func shadowURL(provider, cfgItem string) string { + u, err := url.Parse(cfgItem) + if err != nil { + log.Error("Shadowing Password for %v failed: %v", provider, err) + return cfgItem + } + if u.User != nil { + atIdx := strings.Index(cfgItem, "@") + if atIdx > 0 { + colonIdx := strings.LastIndex(cfgItem[:atIdx], ":") + if colonIdx > 0 { + return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] + } + } + } + return cfgItem +} + +func shadowPassword(provider, cfgItem string) string { + switch provider { + case "redis": + return shadowPasswordKV(cfgItem, ",") + case "mysql": + // root:@tcp(localhost:3306)/macaron?charset=utf8 + atIdx := strings.Index(cfgItem, "@") + if atIdx > 0 { + colonIdx := strings.Index(cfgItem[:atIdx], ":") + if colonIdx > 0 { + return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] + } + } + return cfgItem + case "postgres": + // user=jiahuachen dbname=macaron port=5432 sslmode=disable + if !strings.HasPrefix(cfgItem, "postgres://") { + return shadowPasswordKV(cfgItem, " ") + } + fallthrough + case "couchbase": + return shadowURL(provider, cfgItem) + // postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full + // Notice: use shadowURL + } + return cfgItem +} + +// Config show admin config page +func Config(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.config_summary") + ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSummary"] = true + + ctx.Data["CustomConf"] = setting.CustomConf + ctx.Data["AppUrl"] = setting.AppURL + ctx.Data["AppBuiltWith"] = setting.AppBuiltWith + ctx.Data["Domain"] = setting.Domain + ctx.Data["OfflineMode"] = setting.OfflineMode + ctx.Data["RunUser"] = setting.RunUser + ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode) + ctx.Data["GitVersion"] = git.VersionInfo() + + ctx.Data["AppDataPath"] = setting.AppDataPath + ctx.Data["RepoRootPath"] = setting.RepoRootPath + ctx.Data["CustomRootPath"] = setting.CustomPath + ctx.Data["LogRootPath"] = setting.Log.RootPath + ctx.Data["ScriptType"] = setting.ScriptType + ctx.Data["ReverseProxyAuthUser"] = setting.ReverseProxyAuthUser + ctx.Data["ReverseProxyAuthEmail"] = setting.ReverseProxyAuthEmail + + ctx.Data["SSH"] = setting.SSH + ctx.Data["LFS"] = setting.LFS + + ctx.Data["Service"] = setting.Service + ctx.Data["DbCfg"] = setting.Database + ctx.Data["Webhook"] = setting.Webhook + + ctx.Data["MailerEnabled"] = false + if setting.MailService != nil { + ctx.Data["MailerEnabled"] = true + ctx.Data["Mailer"] = setting.MailService + } + + ctx.Data["CacheAdapter"] = setting.CacheService.Adapter + ctx.Data["CacheInterval"] = setting.CacheService.Interval + + ctx.Data["CacheConn"] = shadowPassword(setting.CacheService.Adapter, setting.CacheService.Conn) + ctx.Data["CacheItemTTL"] = setting.CacheService.TTL + + sessionCfg := setting.SessionConfig + if sessionCfg.Provider == "VirtualSession" { + var realSession session.Options + if err := json.Unmarshal([]byte(sessionCfg.ProviderConfig), &realSession); err != nil { + log.Error("Unable to unmarshall session config for virtual provider config: %s\nError: %v", sessionCfg.ProviderConfig, err) + } + sessionCfg.Provider = realSession.Provider + sessionCfg.ProviderConfig = realSession.ProviderConfig + sessionCfg.CookieName = realSession.CookieName + sessionCfg.CookiePath = realSession.CookiePath + sessionCfg.Gclifetime = realSession.Gclifetime + sessionCfg.Maxlifetime = realSession.Maxlifetime + sessionCfg.Secure = realSession.Secure + sessionCfg.Domain = realSession.Domain + } + sessionCfg.ProviderConfig = shadowPassword(sessionCfg.Provider, sessionCfg.ProviderConfig) + ctx.Data["SessionConfig"] = sessionCfg + + ctx.Data["Git"] = setting.Git + ctx.Data["AccessLogTemplate"] = setting.Log.AccessLogTemplate + ctx.Data["LogSQL"] = setting.Database.LogSQL + + ctx.Data["Loggers"] = log.GetManager().DumpLoggers() + config.GetDynGetter().InvalidateCache() + prepareDeprecatedWarningsAlert(ctx) + + ctx.HTML(http.StatusOK, tplConfig) +} + +func ConfigSettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.config_settings") + ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSettings"] = true + ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString() + ctx.HTML(http.StatusOK, tplConfigSettings) +} + +func ChangeConfig(ctx *context.Context) { + key := strings.TrimSpace(ctx.FormString("key")) + value := ctx.FormString("value") + cfg := setting.Config() + + marshalBool := func(v string) (string, error) { //nolint:unparam + if b, _ := strconv.ParseBool(v); b { + return "true", nil + } + return "false", nil + } + marshalOpenWithApps := func(value string) (string, error) { + lines := strings.Split(value, "\n") + var openWithEditorApps setting.OpenWithEditorAppsType + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + displayName, openURL, ok := strings.Cut(line, "=") + displayName, openURL = strings.TrimSpace(displayName), strings.TrimSpace(openURL) + if !ok || displayName == "" || openURL == "" { + continue + } + openWithEditorApps = append(openWithEditorApps, setting.OpenWithEditorApp{ + DisplayName: strings.TrimSpace(displayName), + OpenURL: strings.TrimSpace(openURL), + }) + } + b, err := json.Marshal(openWithEditorApps) + if err != nil { + return "", err + } + return string(b), nil + } + marshallers := map[string]func(string) (string, error){ + cfg.Picture.DisableGravatar.DynKey(): marshalBool, + cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool, + cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps, + } + marshaller, hasMarshaller := marshallers[key] + if !hasMarshaller { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + marshaledValue, err := marshaller(value) + if err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + if err = system_model.SetSettings(ctx, map[string]string{key: marshaledValue}); err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + + config.GetDynGetter().InvalidateCache() + ctx.JSONOK() +} diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go new file mode 100644 index 0000000..020554a --- /dev/null +++ b/routers/web/admin/diagnosis.go @@ -0,0 +1,68 @@ +// Copyright 2023 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "archive/zip" + "fmt" + "runtime/pprof" + "time" + + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/services/context" +) + +func MonitorDiagnosis(ctx *context.Context) { + seconds := ctx.FormInt64("seconds") + if seconds <= 5 { + seconds = 5 + } + if seconds > 300 { + seconds = 300 + } + + httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ + ContentType: "application/zip", + Disposition: "attachment", + Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")), + }) + + zipWriter := zip.NewWriter(ctx.Resp) + defer zipWriter.Close() + + f, err := zipWriter.CreateHeader(&zip.FileHeader{Name: "goroutine-before.txt", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + _ = pprof.Lookup("goroutine").WriteTo(f, 1) + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "cpu-profile.dat", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + + err = pprof.StartCPUProfile(f) + if err == nil { + time.Sleep(time.Duration(seconds) * time.Second) + pprof.StopCPUProfile() + } else { + _, _ = f.Write([]byte(err.Error())) + } + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "goroutine-after.txt", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + _ = pprof.Lookup("goroutine").WriteTo(f, 1) + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "heap.dat", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + _ = pprof.Lookup("heap").WriteTo(f, 0) +} diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go new file mode 100644 index 0000000..f0d8555 --- /dev/null +++ b/routers/web/admin/emails.go @@ -0,0 +1,182 @@ +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "bytes" + "net/http" + "net/url" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/user" +) + +const ( + tplEmails base.TplName = "admin/emails/list" +) + +// Emails show all emails +func Emails(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.emails") + ctx.Data["PageIsAdminEmails"] = true + + opts := &user_model.SearchEmailOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("page"), + }, + } + + if opts.Page <= 1 { + opts.Page = 1 + } + + type ActiveEmail struct { + user_model.SearchEmailResult + CanChange bool + } + + var ( + baseEmails []*user_model.SearchEmailResult + emails []ActiveEmail + count int64 + err error + orderBy user_model.SearchEmailOrderBy + ) + + ctx.Data["SortType"] = ctx.FormString("sort") + switch ctx.FormString("sort") { + case "email": + orderBy = user_model.SearchEmailOrderByEmail + case "reverseemail": + orderBy = user_model.SearchEmailOrderByEmailReverse + case "username": + orderBy = user_model.SearchEmailOrderByName + case "reverseusername": + orderBy = user_model.SearchEmailOrderByNameReverse + default: + ctx.Data["SortType"] = "email" + orderBy = user_model.SearchEmailOrderByEmail + } + + opts.Keyword = ctx.FormTrim("q") + opts.SortType = orderBy + if len(ctx.FormString("is_activated")) != 0 { + opts.IsActivated = optional.Some(ctx.FormBool("activated")) + } + if len(ctx.FormString("is_primary")) != 0 { + opts.IsPrimary = optional.Some(ctx.FormBool("primary")) + } + + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + baseEmails, count, err = user_model.SearchEmails(ctx, opts) + if err != nil { + ctx.ServerError("SearchEmails", err) + return + } + emails = make([]ActiveEmail, len(baseEmails)) + for i := range baseEmails { + emails[i].SearchEmailResult = *baseEmails[i] + // Don't let the admin deactivate its own primary email address + // We already know the user is admin + emails[i].CanChange = ctx.Doer.ID != emails[i].UID || !emails[i].IsPrimary + } + } + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Emails"] = emails + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplEmails) +} + +var nullByte = []byte{0x00} + +func isKeywordValid(keyword string) bool { + return !bytes.Contains([]byte(keyword), nullByte) +} + +// ActivateEmail serves a POST request for activating/deactivating a user's email +func ActivateEmail(ctx *context.Context) { + truefalse := map[string]bool{"1": true, "0": false} + + uid := ctx.FormInt64("uid") + email := ctx.FormString("email") + primary, okp := truefalse[ctx.FormString("primary")] + activate, oka := truefalse[ctx.FormString("activate")] + + if uid == 0 || len(email) == 0 || !okp || !oka { + ctx.Error(http.StatusBadRequest) + return + } + + log.Info("Changing activation for User ID: %d, email: %s, primary: %v to %v", uid, email, primary, activate) + + if err := user_model.ActivateUserEmail(ctx, uid, email, activate); err != nil { + log.Error("ActivateUserEmail(%v,%v,%v): %v", uid, email, activate, err) + if user_model.IsErrEmailAlreadyUsed(err) { + ctx.Flash.Error(ctx.Tr("admin.emails.duplicate_active")) + } else { + ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err)) + } + } else { + log.Info("Activation for User ID: %d, email: %s, primary: %v changed to %v", uid, email, primary, activate) + ctx.Flash.Info(ctx.Tr("admin.emails.updated")) + } + + redirect, _ := url.Parse(setting.AppSubURL + "/admin/emails") + q := url.Values{} + if val := ctx.FormTrim("q"); len(val) > 0 { + q.Set("q", val) + } + if val := ctx.FormTrim("sort"); len(val) > 0 { + q.Set("sort", val) + } + if val := ctx.FormTrim("is_primary"); len(val) > 0 { + q.Set("is_primary", val) + } + if val := ctx.FormTrim("is_activated"); len(val) > 0 { + q.Set("is_activated", val) + } + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String()) +} + +// DeleteEmail serves a POST request for delete a user's email +func DeleteEmail(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.FormInt64("Uid")) + if err != nil || u == nil { + ctx.ServerError("GetUserByID", err) + return + } + + email, err := user_model.GetEmailAddressByID(ctx, u.ID, ctx.FormInt64("id")) + if err != nil || email == nil { + ctx.ServerError("GetEmailAddressByID", err) + return + } + + if err := user.DeleteEmailAddresses(ctx, u, []string{email.Email}); err != nil { + if user_model.IsErrPrimaryEmailCannotDelete(err) { + ctx.Flash.Error(ctx.Tr("admin.emails.delete_primary_email_error")) + ctx.JSONRedirect("") + return + } + ctx.ServerError("DeleteEmailAddresses", err) + return + } + log.Trace("Email address deleted: %s %s", u.Name, email.Email) + + ctx.Flash.Success(ctx.Tr("admin.emails.deletion_success")) + ctx.JSONRedirect("") +} diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go new file mode 100644 index 0000000..cdca0a5 --- /dev/null +++ b/routers/web/admin/hooks.go @@ -0,0 +1,73 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +const ( + // tplAdminHooks template path to render hook settings + tplAdminHooks base.TplName = "admin/hooks" +) + +// DefaultOrSystemWebhooks renders both admin default and system webhook list pages +func DefaultOrSystemWebhooks(ctx *context.Context) { + var err error + + ctx.Data["Title"] = ctx.Tr("admin.hooks") + ctx.Data["PageIsAdminSystemHooks"] = true + ctx.Data["PageIsAdminDefaultHooks"] = true + + def := make(map[string]any, len(ctx.Data)) + sys := make(map[string]any, len(ctx.Data)) + for k, v := range ctx.Data { + def[k] = v + sys[k] = v + } + + sys["Title"] = ctx.Tr("admin.systemhooks") + sys["Description"] = ctx.Tr("admin.systemhooks.desc", "https://forgejo.org/docs/latest/user/webhooks/") + sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, false) + sys["BaseLink"] = setting.AppSubURL + "/admin/hooks" + sys["BaseLinkNew"] = setting.AppSubURL + "/admin/system-hooks" + sys["WebhookList"] = webhook_service.List() + if err != nil { + ctx.ServerError("GetWebhooksAdmin", err) + return + } + + def["Title"] = ctx.Tr("admin.defaulthooks") + def["Description"] = ctx.Tr("admin.defaulthooks.desc", "https://forgejo.org/docs/latest/user/webhooks/") + def["Webhooks"], err = webhook.GetDefaultWebhooks(ctx) + def["BaseLink"] = setting.AppSubURL + "/admin/hooks" + def["BaseLinkNew"] = setting.AppSubURL + "/admin/default-hooks" + def["WebhookList"] = webhook_service.List() + if err != nil { + ctx.ServerError("GetWebhooksAdmin", err) + return + } + + ctx.Data["DefaultWebhooks"] = def + ctx.Data["SystemWebhooks"] = sys + + ctx.HTML(http.StatusOK, tplAdminHooks) +} + +// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook +func DeleteDefaultOrSystemWebhook(ctx *context.Context) { + if err := webhook.DeleteDefaultSystemWebhook(ctx, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/admin/hooks") +} diff --git a/routers/web/admin/main_test.go b/routers/web/admin/main_test.go new file mode 100644 index 0000000..e1294dd --- /dev/null +++ b/routers/web/admin/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/admin/notice.go b/routers/web/admin/notice.go new file mode 100644 index 0000000..36303cb --- /dev/null +++ b/routers/web/admin/notice.go @@ -0,0 +1,78 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/models/db" + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplNotices base.TplName = "admin/notice" +) + +// Notices show notices for admin +func Notices(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.notices") + ctx.Data["PageIsAdminNotices"] = true + + total := system_model.CountNotices(ctx) + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + notices, err := system_model.Notices(ctx, page, setting.UI.Admin.NoticePagingNum) + if err != nil { + ctx.ServerError("Notices", err) + return + } + ctx.Data["Notices"] = notices + + ctx.Data["Total"] = total + + ctx.Data["Page"] = context.NewPagination(int(total), setting.UI.Admin.NoticePagingNum, page, 5) + + ctx.HTML(http.StatusOK, tplNotices) +} + +// DeleteNotices delete the specific notices +func DeleteNotices(ctx *context.Context) { + strs := ctx.FormStrings("ids[]") + ids := make([]int64, 0, len(strs)) + for i := range strs { + id, _ := strconv.ParseInt(strs[i], 10, 64) + if id > 0 { + ids = append(ids, id) + } + } + + if err := db.DeleteByIDs[system_model.Notice](ctx, ids...); err != nil { + ctx.Flash.Error("DeleteNoticesByIDs: " + err.Error()) + ctx.Status(http.StatusInternalServerError) + } else { + ctx.Flash.Success(ctx.Tr("admin.notices.delete_success")) + ctx.Status(http.StatusOK) + } +} + +// EmptyNotices delete all the notices +func EmptyNotices(ctx *context.Context) { + if err := system_model.DeleteNotices(ctx, 0, 0); err != nil { + ctx.ServerError("DeleteNotices", err) + return + } + + log.Trace("System notices deleted by admin (%s): [start: %d]", ctx.Doer.Name, 0) + ctx.Flash.Success(ctx.Tr("admin.notices.delete_success")) + ctx.Redirect(setting.AppSubURL + "/admin/notices") +} diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go new file mode 100644 index 0000000..cea28f8 --- /dev/null +++ b/routers/web/admin/orgs.go @@ -0,0 +1,39 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" +) + +const ( + tplOrgs base.TplName = "admin/org/list" +) + +// Organizations show all the organizations +func Organizations(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.organizations") + ctx.Data["PageIsAdminOrganizations"] = true + + if ctx.FormString("sort") == "" { + ctx.SetFormString("sort", UserSearchDefaultAdminSort) + } + + explore.RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeOrganization, + IncludeReserved: true, // administrator needs to list all accounts include reserved + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.OrgPagingNum, + }, + Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + }, tplOrgs) +} diff --git a/routers/web/admin/packages.go b/routers/web/admin/packages.go new file mode 100644 index 0000000..39f064a --- /dev/null +++ b/routers/web/admin/packages.go @@ -0,0 +1,113 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup" +) + +const ( + tplPackagesList base.TplName = "admin/packages/list" +) + +// Packages shows all packages +func Packages(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + query := ctx.FormTrim("q") + packageType := ctx.FormTrim("type") + sort := ctx.FormTrim("sort") + + pvs, total, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + Type: packages_model.Type(packageType), + Name: packages_model.SearchValue{Value: query}, + Sort: sort, + IsInternal: optional.Some(false), + Paginator: &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + }, + }) + if err != nil { + ctx.ServerError("SearchVersions", err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + totalBlobSize, err := packages_model.GetTotalBlobSize(ctx) + if err != nil { + ctx.ServerError("GetTotalBlobSize", err) + return + } + + totalUnreferencedBlobSize, err := packages_model.GetTotalUnreferencedBlobSize(ctx) + if err != nil { + ctx.ServerError("CalculateBlobSize", err) + return + } + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsAdminPackages"] = true + ctx.Data["Query"] = query + ctx.Data["PackageType"] = packageType + ctx.Data["AvailableTypes"] = packages_model.TypeList + ctx.Data["SortType"] = sort + ctx.Data["PackageDescriptors"] = pds + ctx.Data["TotalCount"] = total + ctx.Data["TotalBlobSize"] = totalBlobSize - totalUnreferencedBlobSize + ctx.Data["TotalUnreferencedBlobSize"] = totalUnreferencedBlobSize + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + pager.AddParamString("q", query) + pager.AddParamString("type", packageType) + pager.AddParamString("sort", sort) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackagesList) +} + +// DeletePackageVersion deletes a package version +func DeletePackageVersion(ctx *context.Context) { + pv, err := packages_model.GetVersionByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + ctx.ServerError("RemovePackageVersion", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) + ctx.JSONRedirect(setting.AppSubURL + "/admin/packages?page=" + url.QueryEscape(ctx.FormString("page")) + "&q=" + url.QueryEscape(ctx.FormString("q")) + "&type=" + url.QueryEscape(ctx.FormString("type"))) +} + +func CleanupExpiredData(ctx *context.Context) { + if err := packages_cleanup_service.CleanupExpiredData(ctx, time.Duration(0)); err != nil { + ctx.ServerError("CleanupExpiredData", err) + return + } + + ctx.Flash.Success(ctx.Tr("admin.packages.cleanup.success")) + ctx.Redirect(setting.AppSubURL + "/admin/packages") +} diff --git a/routers/web/admin/queue.go b/routers/web/admin/queue.go new file mode 100644 index 0000000..246ab37 --- /dev/null +++ b/routers/web/admin/queue.go @@ -0,0 +1,89 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +func Queues(ctx *context.Context) { + if !setting.IsProd { + initTestQueueOnce() + } + ctx.Data["Title"] = ctx.Tr("admin.monitor.queues") + ctx.Data["PageIsAdminMonitorQueue"] = true + ctx.Data["Queues"] = queue.GetManager().ManagedQueues() + ctx.HTML(http.StatusOK, tplQueue) +} + +// QueueManage shows details for a specific queue +func QueueManage(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(http.StatusNotFound) + return + } + ctx.Data["Title"] = ctx.Tr("admin.monitor.queue", mq.GetName()) + ctx.Data["PageIsAdminMonitor"] = true + ctx.Data["Queue"] = mq + ctx.HTML(http.StatusOK, tplQueueManage) +} + +// QueueSet sets the maximum number of workers and other settings for this queue +func QueueSet(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(http.StatusNotFound) + return + } + + maxNumberStr := ctx.FormString("max-number") + + var err error + var maxNumber int + if len(maxNumberStr) > 0 { + maxNumber, err = strconv.Atoi(maxNumberStr) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.settings.maxnumberworkers.error")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + if maxNumber < -1 { + maxNumber = -1 + } + } else { + maxNumber = mq.GetWorkerMaxNumber() + } + + mq.SetWorkerMaxNumber(maxNumber) + ctx.Flash.Success(ctx.Tr("admin.monitor.queue.settings.changed")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) +} + +func QueueRemoveAllItems(ctx *context.Context) { + // Queue in Forgejo doesn't have transaction support + // So in rare cases, the queue could be corrupted/out-of-sync + // Site admin could remove all items from the queue to make it work again + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(http.StatusNotFound) + return + } + + if err := mq.RemoveAllItems(ctx); err != nil { + ctx.ServerError("RemoveAllItems", err) + return + } + + ctx.Flash.Success(ctx.Tr("admin.monitor.queue.settings.remove_all_items_done")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) +} diff --git a/routers/web/admin/queue_tester.go b/routers/web/admin/queue_tester.go new file mode 100644 index 0000000..8f713b3 --- /dev/null +++ b/routers/web/admin/queue_tester.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "runtime/pprof" + "sync" + "time" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" +) + +var testQueueOnce sync.Once + +// initTestQueueOnce initializes the test queue for dev mode +// the test queue will also be shown in the queue list +// developers could see the queue length / worker number / items number on the admin page and try to remove the items +func initTestQueueOnce() { + testQueueOnce.Do(func() { + ctx, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().ShutdownContext(), "TestQueue", process.SystemProcessType, false) + qs := setting.QueueSettings{ + Name: "test-queue", + Type: "channel", + Length: 20, + BatchLength: 2, + MaxWorkers: 3, + } + testQueue, err := queue.NewWorkerPoolQueueWithContext(ctx, "test-queue", qs, func(t ...int64) (unhandled []int64) { + for range t { + select { + case <-graceful.GetManager().ShutdownContext().Done(): + case <-time.After(5 * time.Second): + } + } + return nil + }, true) + if err != nil { + log.Error("unable to create test queue: %v", err) + return + } + + queue.GetManager().AddManagedQueue(testQueue) + testQueue.SetWorkerMaxNumber(5) + go graceful.GetManager().RunWithCancel(testQueue) + go func() { + pprof.SetGoroutineLabels(ctx) + defer finished() + + cnt := int64(0) + adding := true + for { + select { + case <-ctx.Done(): + case <-time.After(500 * time.Millisecond): + if adding { + if testQueue.GetQueueItemNumber() == qs.Length { + adding = false + } + } else { + if testQueue.GetQueueItemNumber() == 0 { + adding = true + } + } + if adding { + _ = testQueue.Push(cnt) + cnt++ + } + } + } + }() + }) +} diff --git a/routers/web/admin/repos.go b/routers/web/admin/repos.go new file mode 100644 index 0000000..d0339fd --- /dev/null +++ b/routers/web/admin/repos.go @@ -0,0 +1,163 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplRepos base.TplName = "admin/repo/list" + tplUnadoptedRepos base.TplName = "admin/repo/unadopted" +) + +// Repos show all the repositories +func Repos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.repositories") + ctx.Data["PageIsAdminRepositories"] = true + + explore.RenderRepoSearch(ctx, &explore.RepoSearchOptions{ + Private: true, + PageSize: setting.UI.Admin.RepoPagingNum, + TplName: tplRepos, + OnlyShowRelevant: false, + }) +} + +// DeleteRepo delete one repository +func DeleteRepo(ctx *context.Context) { + repo, err := repo_model.GetRepositoryByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + + if ctx.Repo != nil && ctx.Repo.GitRepo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repo.ID { + ctx.Repo.GitRepo.Close() + } + + if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { + ctx.ServerError("DeleteRepository", err) + return + } + log.Trace("Repository deleted: %s", repo.FullName()) + + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/admin/repos?page=" + url.QueryEscape(ctx.FormString("page")) + "&sort=" + url.QueryEscape(ctx.FormString("sort"))) +} + +// UnadoptedRepos lists the unadopted repositories +func UnadoptedRepos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.repositories") + ctx.Data["PageIsAdminRepositories"] = true + + opts := db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("page"), + } + + if opts.Page <= 0 { + opts.Page = 1 + } + + ctx.Data["CurrentPage"] = opts.Page + + doSearch := ctx.FormBool("search") + + ctx.Data["search"] = doSearch + q := ctx.FormString("q") + + if !doSearch { + pager := context.NewPagination(0, opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "search", "search") + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplUnadoptedRepos) + return + } + + ctx.Data["Keyword"] = q + repoNames, count, err := repo_service.ListUnadoptedRepositories(ctx, q, &opts) + if err != nil { + ctx.ServerError("ListUnadoptedRepositories", err) + return + } + ctx.Data["Dirs"] = repoNames + pager := context.NewPagination(count, opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "search", "search") + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplUnadoptedRepos) +} + +// AdoptOrDeleteRepository adopts or deletes a repository +func AdoptOrDeleteRepository(ctx *context.Context) { + dir := ctx.FormString("id") + action := ctx.FormString("action") + page := ctx.FormString("page") + q := ctx.FormString("q") + + dirSplit := strings.SplitN(dir, "/", 2) + if len(dirSplit) != 2 { + ctx.Redirect(setting.AppSubURL + "/admin/repos") + return + } + + ctxUser, err := user_model.GetUserByName(ctx, dirSplit[0]) + if err != nil { + if user_model.IsErrUserNotExist(err) { + log.Debug("User does not exist: %s", dirSplit[0]) + ctx.Redirect(setting.AppSubURL + "/admin/repos") + return + } + ctx.ServerError("GetUserByName", err) + return + } + + repoName := dirSplit[1] + + // check not a repo + has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName) + if err != nil { + ctx.ServerError("IsRepositoryExist", err) + return + } + isDir, err := util.IsDir(repo_model.RepoPath(ctxUser.Name, repoName)) + if err != nil { + ctx.ServerError("IsDir", err) + return + } + if has || !isDir { + // Fallthrough to failure mode + } else if action == "adopt" { + if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{ + Name: dirSplit[1], + IsPrivate: true, + }); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir)) + } else if action == "delete" { + if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, dirSplit[1]); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir)) + } + ctx.Redirect(setting.AppSubURL + "/admin/repos/unadopted?search=true&q=" + url.QueryEscape(q) + "&page=" + url.QueryEscape(page)) +} diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go new file mode 100644 index 0000000..d73290a --- /dev/null +++ b/routers/web/admin/runners.go @@ -0,0 +1,13 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/admin/actions/runners") +} diff --git a/routers/web/admin/stacktrace.go b/routers/web/admin/stacktrace.go new file mode 100644 index 0000000..d6def94 --- /dev/null +++ b/routers/web/admin/stacktrace.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "runtime" + + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// Stacktrace show admin monitor goroutines page +func Stacktrace(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor") + ctx.Data["PageIsAdminMonitorStacktrace"] = true + + ctx.Data["GoroutineCount"] = runtime.NumGoroutine() + + show := ctx.FormString("show") + ctx.Data["ShowGoroutineList"] = show + // by default, do not do anything which might cause server errors, to avoid unnecessary 500 pages. + // this page is the entrance of the chance to collect diagnosis report. + if show != "" { + showNoSystem := show == "process" + processStacks, processCount, _, err := process.GetManager().ProcessStacktraces(false, showNoSystem) + if err != nil { + ctx.ServerError("GoroutineStacktrace", err) + return + } + + ctx.Data["ProcessStacks"] = processStacks + ctx.Data["ProcessCount"] = processCount + } + + ctx.HTML(http.StatusOK, tplStacktrace) +} + +// StacktraceCancel cancels a process +func StacktraceCancel(ctx *context.Context) { + pid := ctx.Params("pid") + process.GetManager().Cancel(process.IDType(pid)) + ctx.JSONRedirect(setting.AppSubURL + "/admin/monitor/stacktrace") +} diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go new file mode 100644 index 0000000..25fef5f --- /dev/null +++ b/routers/web/admin/users.go @@ -0,0 +1,557 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/explore" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplUsers base.TplName = "admin/user/list" + tplUserNew base.TplName = "admin/user/new" + tplUserView base.TplName = "admin/user/view" + tplUserEdit base.TplName = "admin/user/edit" +) + +// UserSearchDefaultAdminSort is the default sort type for admin view +const UserSearchDefaultAdminSort = "alphabetically" + +// Users show all the users +func Users(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users") + ctx.Data["PageIsAdminUsers"] = true + + extraParamStrings := map[string]string{} + statusFilterKeys := []string{"is_active", "is_admin", "is_restricted", "is_2fa_enabled", "is_prohibit_login"} + statusFilterMap := map[string]string{} + for _, filterKey := range statusFilterKeys { + paramKey := "status_filter[" + filterKey + "]" + paramVal := ctx.FormString(paramKey) + statusFilterMap[filterKey] = paramVal + if paramVal != "" { + extraParamStrings[paramKey] = paramVal + } + } + + sortType := ctx.FormString("sort") + if sortType == "" { + sortType = UserSearchDefaultAdminSort + ctx.SetFormString("sort", sortType) + } + ctx.PageData["adminUserListSearchForm"] = map[string]any{ + "StatusFilterMap": statusFilterMap, + "SortType": sortType, + } + + explore.RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeIndividual, + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + }, + SearchByEmail: true, + IsActive: util.OptionalBoolParse(statusFilterMap["is_active"]), + IsAdmin: util.OptionalBoolParse(statusFilterMap["is_admin"]), + IsRestricted: util.OptionalBoolParse(statusFilterMap["is_restricted"]), + IsTwoFactorEnabled: util.OptionalBoolParse(statusFilterMap["is_2fa_enabled"]), + IsProhibitLogin: util.OptionalBoolParse(statusFilterMap["is_prohibit_login"]), + IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones + ExtraParamStrings: extraParamStrings, + }, tplUsers) +} + +// NewUser render adding a new user page +func NewUser(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.new_account") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + + ctx.Data["login_type"] = "0-0" + + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) + if err != nil { + ctx.ServerError("auth.Sources", err) + return + } + ctx.Data["Sources"] = sources + + ctx.Data["CanSendEmail"] = setting.MailService != nil + ctx.HTML(http.StatusOK, tplUserNew) +} + +// NewUserPost response for adding a new user +func NewUserPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminCreateUserForm) + ctx.Data["Title"] = ctx.Tr("admin.users.new_account") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) + if err != nil { + ctx.ServerError("auth.Sources", err) + return + } + ctx.Data["Sources"] = sources + + ctx.Data["CanSendEmail"] = setting.MailService != nil + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUserNew) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + LoginType: auth.Plain, + } + + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(true), + Visibility: &form.Visibility, + } + + if len(form.LoginType) > 0 { + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + lType, _ := strconv.ParseInt(fields[0], 10, 0) + u.LoginType = auth.Type(lType) + u.LoginSource, _ = strconv.ParseInt(fields[1], 10, 64) + u.LoginName = form.LoginName + } + } + if u.LoginType == auth.NoType || u.LoginType == auth.Plain { + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form) + return + } + if !password.IsComplexEnough(form.Password) { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserNew, &form) + return + } + if err := password.IsPwned(ctx, form.Password); err != nil { + ctx.Data["Err_Password"] = true + errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords") + if password.IsErrIsPwnedRequest(err) { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.RenderWithErr(errMsg, tplUserNew, &form) + return + } + u.MustChangePassword = form.MustChangePassword + } + + if err := user_model.AdminCreateUser(ctx, u, overwriteDefault); err != nil { + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserNew, &form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserNew, &form) + case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserNew, &form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tplUserNew, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplUserNew, &form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(db.ErrNameCharsNotAllowed).Name), tplUserNew, &form) + default: + ctx.ServerError("CreateUser", err) + } + return + } + + if !user_model.IsEmailDomainAllowed(u.Email) { + ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email)) + } + + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) + + // Send email notification. + if form.SendNotify { + mailer.SendRegisterNotifyMail(u) + } + + ctx.Flash.Success(ctx.Tr("admin.users.new_success", u.Name)) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10)) +} + +func prepareUserInfo(ctx *context.Context) *user_model.User { + u, err := user_model.GetUserByID(ctx, ctx.ParamsInt64(":userid")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Redirect(setting.AppSubURL + "/admin/users") + } else { + ctx.ServerError("GetUserByID", err) + } + return nil + } + ctx.Data["User"] = u + + if u.LoginSource > 0 { + ctx.Data["LoginSource"], err = auth.GetSourceByID(ctx, u.LoginSource) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return nil + } + } else { + ctx.Data["LoginSource"] = &auth.Source{} + } + + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{}) + if err != nil { + ctx.ServerError("auth.Sources", err) + return nil + } + ctx.Data["Sources"] = sources + + hasTOTP, err := auth.HasTwoFactorByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("auth.HasTwoFactorByUID", err) + return nil + } + hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("auth.HasWebAuthnRegistrationsByUID", err) + return nil + } + ctx.Data["TwoFactorEnabled"] = hasTOTP || hasWebAuthn + + return u +} + +func ViewUser(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.details") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation + ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: u.ID, + OrderBy: db.SearchOrderByAlphabetically, + Private: true, + Collaborate: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + ctx.Data["Repos"] = repos + ctx.Data["ReposTotal"] = int(count) + + emails, err := user_model.GetEmailAddresses(ctx, u.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + ctx.Data["Emails"] = emails + ctx.Data["EmailsTotal"] = len(emails) + + orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{ + ListOptions: db.ListOptionsAll, + UserID: u.ID, + IncludePrivate: true, + }) + if err != nil { + ctx.ServerError("FindOrgs", err) + return + } + + ctx.Data["Users"] = orgs // needed to be able to use explore/user_list template + ctx.Data["OrgsTotal"] = len(orgs) + + ctx.HTML(http.StatusOK, tplUserView) +} + +func editUserCommon(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation + ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) +} + +// EditUser show editing user page +func EditUser(ctx *context.Context) { + editUserCommon(ctx) + prepareUserInfo(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplUserEdit) +} + +// EditUserPost response for editing user +func EditUserPost(ctx *context.Context) { + editUserCommon(ctx) + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*forms.AdminEditUserForm) + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUserEdit) + return + } + + if form.UserName != "" { + if err := user_service.RenameUser(ctx, u, form.UserName); err != nil { + switch { + case user_model.IsErrUserIsNotLocal(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_change_not_local_user"), tplUserEdit, &form) + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserEdit, &form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", form.UserName), tplUserEdit, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", form.UserName), tplUserEdit, &form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", form.UserName), tplUserEdit, &form) + default: + ctx.ServerError("RenameUser", err) + } + return + } + } + + authOpts := &user_service.UpdateAuthOptions{ + Password: optional.FromNonDefault(form.Password), + LoginName: optional.Some(form.LoginName), + } + + // skip self Prohibit Login + if ctx.Doer.ID == u.ID { + authOpts.ProhibitLogin = optional.Some(false) + } else { + authOpts.ProhibitLogin = optional.Some(form.ProhibitLogin) + } + + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + authSource, _ := strconv.ParseInt(fields[1], 10, 64) + + authOpts.LoginSource = optional.Some(authSource) + } + + if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form) + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form) + case errors.Is(err, password.ErrIsPwned): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplUserEdit, &form) + case password.IsErrIsPwnedRequest(err): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) + default: + ctx.ServerError("UpdateUser", err) + } + return + } + + if form.Email != "" { + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, u, form.Email); err != nil { + switch { + case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserEdit, &form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserEdit, &form) + default: + ctx.ServerError("AddOrSetPrimaryEmailAddress", err) + } + return + } + if !user_model.IsEmailDomainAllowed(form.Email) { + ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", form.Email)) + } + } + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Pronouns: optional.Some(form.Pronouns), + IsActive: optional.Some(form.Active), + IsAdmin: optional.Some(form.Admin), + AllowGitHook: optional.Some(form.AllowGitHook), + AllowImportLocal: optional.Some(form.AllowImportLocal), + MaxRepoCreation: optional.Some(form.MaxRepoCreation), + AllowCreateOrganization: optional.Some(form.AllowCreateOrganization), + IsRestricted: optional.Some(form.Restricted), + Visibility: optional.Some(form.Visibility), + Language: optional.Some(form.Language), + } + + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if models.IsErrDeleteLastAdminUser(err) { + ctx.RenderWithErr(ctx.Tr("auth.last_admin"), tplUserEdit, &form) + } else { + ctx.ServerError("UpdateUser", err) + } + return + } + log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name) + + if form.Reset2FA { + tf, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("auth.GetTwoFactorByUID", err) + return + } else if tf != nil { + if err := auth.DeleteTwoFactorByID(ctx, tf.ID, u.ID); err != nil { + ctx.ServerError("auth.DeleteTwoFactorByID", err) + return + } + } + + wn, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("auth.GetTwoFactorByUID", err) + return + } + for _, cred := range wn { + if _, err := auth.DeleteCredential(ctx, cred.ID, u.ID); err != nil { + ctx.ServerError("auth.DeleteCredential", err) + return + } + } + } + + ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) +} + +// DeleteUser response for deleting a user +func DeleteUser(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.ParamsInt64(":userid")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + // admin should not delete themself + if u.ID == ctx.Doer.ID { + ctx.Flash.Error(ctx.Tr("admin.users.cannot_delete_self")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + return + } + + if err = user_service.DeleteUser(ctx, u, ctx.FormBool("purge")); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_own_repo")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_has_org")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrUserOwnPackages(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrDeleteLastAdminUser(err): + ctx.Flash.Error(ctx.Tr("auth.last_admin")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + default: + ctx.ServerError("DeleteUser", err) + } + return + } + log.Trace("Account deleted by admin (%s): %s", ctx.Doer.Name, u.Name) + + ctx.Flash.Success(ctx.Tr("admin.users.deletion_success")) + ctx.Redirect(setting.AppSubURL + "/admin/users") +} + +// AvatarPost response for change user's avatar request +func AvatarPost(ctx *context.Context) { + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*forms.AvatarForm) + if err := user_setting.UpdateAvatarSetting(ctx, form, u); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.update_user_avatar_success")) + } + + ctx.Redirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10)) +} + +// DeleteAvatar render delete avatar page +func DeleteAvatar(ctx *context.Context) { + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + if err := user_service.DeleteAvatar(ctx, u); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.JSONRedirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10)) +} diff --git a/routers/web/admin/users_test.go b/routers/web/admin/users_test.go new file mode 100644 index 0000000..ae3b130 --- /dev/null +++ b/routers/web/admin/users_test.go @@ -0,0 +1,200 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewUserPost_MustChangePassword(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: true, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + assert.True(t, u.MustChangePassword) +} + +func TestNewUserPost_MustChangePasswordFalse(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + assert.False(t, u.MustChangePassword) +} + +func TestNewUserPost_InvalidEmail(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io\r\n" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestNewUserPost_VisibilityDefaultPublic(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + // As default user visibility + assert.Equal(t, setting.Service.DefaultUserVisibilityMode, u.Visibility) +} + +func TestNewUserPost_VisibilityPrivate(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + Visibility: api.VisibleTypePrivate, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + // As default user visibility + assert.True(t, u.Visibility.IsPrivate()) +} diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go new file mode 100644 index 0000000..f93177b --- /dev/null +++ b/routers/web/auth/2fa.go @@ -0,0 +1,163 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" +) + +var ( + tplTwofa base.TplName = "user/auth/twofa" + tplTwofaScratch base.TplName = "user/auth/twofa_scratch" +) + +// TwoFactor shows the user a two-factor authentication page. +func TwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + + if 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 := auth.GetTwoFactorByUID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + ok, err := twofa.ValidateTOTP(form.Passcode) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ok && twofa.LastUsedPasscode != form.Passcode { + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := user_model.GetUserByID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ctx.Session.Get("linkAccount") != nil { + err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + } + + twofa.LastUsedPasscode = form.Passcode + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + 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") + + 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 := auth.GetTwoFactorByUID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + if twofa.VerifyScratchToken(form.Token) { + // Invalidate the scratch token. + _, err = twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := user_model.GetUserByID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + if ctx.Written() { + return + } + 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{}) +} diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go new file mode 100644 index 0000000..9649ddd --- /dev/null +++ b/routers/web/auth/auth.go @@ -0,0 +1,879 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/session" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + notify_service "code.gitea.io/gitea/services/notify" + user_service "code.gitea.io/gitea/services/user" + + "github.com/markbates/goth" +) + +const ( + // 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" +) + +// autoSignIn reads cookie and try to auto-login. +func autoSignIn(ctx *context.Context) (bool, error) { + isSucceed := false + defer func() { + if !isSucceed { + ctx.DeleteSiteCookie(setting.CookieRememberName) + } + }() + + authCookie := ctx.GetSiteCookie(setting.CookieRememberName) + if len(authCookie) == 0 { + return false, nil + } + + u, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization, false) + if err != nil { + return false, fmt.Errorf("VerifyUserAuthorizationToken: %w", err) + } + if u == nil { + return false, nil + } + + isSucceed = true + + if err := updateSession(ctx, nil, map[string]any{ + // Set session IDs + "uid": u.ID, + }); err != nil { + return false, fmt.Errorf("unable to updateSession: %w", err) + } + + if err := resetLocale(ctx, u); err != nil { + return false, err + } + + ctx.Csrf.DeleteCookie(ctx) + return true, nil +} + +func resetLocale(ctx *context.Context, u *user_model.User) error { + // Language setting of the user overwrites the one previously set + // If the user does not have a locale set, we save the current one. + if u.Language == "" { + opts := &user_service.UpdateOptions{ + Language: optional.Some(ctx.Locale.Language()), + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + return err + } + } + + middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + + if ctx.Locale.Language() != u.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + + return nil +} + +func RedirectAfterLogin(ctx *context.Context) { + redirectTo := ctx.FormString("redirect_to") + if redirectTo == "" { + redirectTo = ctx.GetSiteCookie("redirect_to") + } + middleware.DeleteRedirectToCookie(ctx.Resp) + nextRedirectTo := setting.AppSubURL + string(setting.LandingPageURL) + if setting.LandingPageURL == setting.LandingPageLogin { + nextRedirectTo = setting.AppSubURL + "/" // do not cycle-redirect to the login page + } + ctx.RedirectToFirst(redirectTo, nextRedirectTo) +} + +func CheckAutoLogin(ctx *context.Context) bool { + isSucceed, err := autoSignIn(ctx) // try to auto-login + if err != nil { + ctx.ServerError("autoSignIn", err) + return true + } + + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + if isSucceed { + RedirectAfterLogin(ctx) + return true + } + + return false +} + +// SignIn render sign in page +func SignIn(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + if CheckAutoLogin(ctx) { + return + } + + if ctx.IsSigned { + RedirectAfterLogin(ctx) + return + } + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + 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"] = auth.IsSSPIEnabled(ctx) + + if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { + context.SetCaptchaData(ctx) + } + + ctx.HTML(http.StatusOK, tplSignIn) +} + +// SignInPost response for sign in request +func SignInPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + 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"] = auth.IsSSPIEnabled(ctx) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignIn) + return + } + + form := web.GetForm(ctx).(*forms.SignInForm) + + if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { + context.SetCaptchaData(ctx) + + context.VerifyCaptcha(ctx, tplSignIn, form) + if ctx.Written() { + return + } + } + + u, source, err := auth_service.UserSignIn(ctx, form.UserName, form.Password) + if err != nil { + if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form) + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if user_model.IsErrEmailAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSignIn, &form) + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if user_model.IsErrUserProhibitLogin(err) { + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if user_model.IsErrUserInactive(err) { + if setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, TplActivate) + } else { + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + + // Now handle 2FA: + + // First of all if the source can skip local two fa we're done + if skipper, ok := source.Cfg.(auth_service.LocalTwoFASkipper); ok && skipper.IsSkipLocalTwoFA() { + handleSignIn(ctx, u, form.Remember) + return + } + + // If this user is enrolled in 2FA TOTP, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Check if the user has webauthn registration + hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if !hasTOTPtwofa && !hasWebAuthnTwofa { + // No two factor auth configured we can sign in the user + handleSignIn(ctx, u, form.Remember) + return + } + + updates := map[string]any{ + // User will need to use 2FA TOTP or WebAuthn, save data + "twofaUid": u.ID, + "twofaRemember": form.Remember, + } + if hasTOTPtwofa { + // User will need to use WebAuthn, save data + updates["totpEnrolled"] = u.ID + } + if err := updateSession(ctx, nil, updates); err != nil { + ctx.ServerError("UserSignIn: Unable to update session", err) + return + } + + // If we have WebAuthn redirect there first + if hasWebAuthnTwofa { + ctx.Redirect(setting.AppSubURL + "/user/webauthn") + return + } + + // Fallback to 2FA + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// This handles the final part of the sign-in process of the user. +func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) { + redirect := handleSignInFull(ctx, u, remember, true) + if ctx.Written() { + return + } + ctx.Redirect(redirect) +} + +func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string { + if remember { + if err := ctx.SetLTACookie(u); err != nil { + ctx.ServerError("GenerateAuthToken", err) + return setting.AppSubURL + "/" + } + } + + if err := updateSession(ctx, []string{ + // Delete the openid, 2fa and linkaccount data + "openid_verified_uri", + "openid_signin_remember", + "openid_determined_email", + "openid_determined_username", + "twofaUid", + "twofaRemember", + "linkAccount", + }, map[string]any{ + "uid": u.ID, + }); err != nil { + ctx.ServerError("RegenerateSession", err) + return setting.AppSubURL + "/" + } + + // Language setting of the user overwrites the one previously set + // If the user does not have a locale set, we save the current one. + if u.Language == "" { + opts := &user_service.UpdateOptions{ + Language: optional.Some(ctx.Locale.Language()), + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, ctx.Locale.Language())) + return setting.AppSubURL + "/" + } + } + + middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + + if ctx.Locale.Language() != u.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + + // Clear whatever CSRF cookie has right now, force to generate a new one + ctx.Csrf.DeleteCookie(ctx) + + // Register last login + if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + ctx.ServerError("UpdateUser", err) + return setting.AppSubURL + "/" + } + + redirectTo := ctx.GetSiteCookie("redirect_to") + if redirectTo != "" { + middleware.DeleteRedirectToCookie(ctx.Resp) + } + if obeyRedirect { + return ctx.RedirectToFirst(redirectTo) + } + if !httplib.IsRiskyRedirectURL(redirectTo) { + return redirectTo + } + return setting.AppSubURL + "/" +} + +func getUserName(gothUser *goth.User) (string, error) { + switch setting.OAuth2Client.Username { + case setting.OAuth2UsernameEmail: + return user_model.NormalizeUserName(strings.Split(gothUser.Email, "@")[0]) + case setting.OAuth2UsernameNickname: + return user_model.NormalizeUserName(gothUser.NickName) + default: // OAuth2UsernameUserid + return gothUser.UserID, nil + } +} + +// HandleSignOut resets the session and sets the cookies +func HandleSignOut(ctx *context.Context) { + _ = ctx.Session.Flush() + _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) + ctx.DeleteSiteCookie(setting.CookieRememberName) + ctx.Csrf.DeleteCookie(ctx) + middleware.DeleteRedirectToCookie(ctx.Resp) +} + +// SignOut sign out from login status +func SignOut(ctx *context.Context) { + if ctx.Doer != nil { + eventsource.GetManager().SendMessage(ctx.Doer.ID, &eventsource.Event{ + Name: "logout", + Data: ctx.Session.ID(), + }) + } + HandleSignOut(ctx) + ctx.JSONRedirect(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" + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignUp", err) + return + } + + ctx.Data["OAuth2Providers"] = oauth2Providers + context.SetCaptchaData(ctx) + + ctx.Data["PageIsSignUp"] = true + + // Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration + + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + 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" + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignUp", err) + return + } + + ctx.Data["OAuth2Providers"] = oauth2Providers + context.SetCaptchaData(ctx) + + 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 + } + + context.VerifyCaptcha(ctx, tplSignUp, form) + if ctx.Written() { + 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.Locale), tplSignUp, &form) + return + } + if err := password.IsPwned(ctx, form.Password); err != nil { + errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords") + if password.IsErrIsPwnedRequest(err) { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(errMsg, tplSignUp, &form) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + } + + if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) { + // error already handled + return + } + + ctx.Flash.Success(ctx.Tr("auth.sign_up_successful")) + handleSignIn(ctx, u, false) +} + +// createAndHandleCreatedUser calls createUserInContext and +// then handleUserCreated. +func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool { + if !createUserInContext(ctx, tpl, form, u, overwrites, 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 any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) { + if err := user_model.CreateUser(ctx, u, overwrites); err != nil { + if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { + if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { + var user *user_model.User + user = &user_model.User{Name: u.Name} + hasUser, err := user_model.GetUser(ctx, user) + if !hasUser || err != nil { + user = &user_model.User{Email: u.Email} + hasUser, err = user_model.GetUser(ctx, user) + if !hasUser || err != nil { + ctx.ServerError("UserLinkAccount", err) + return false + } + } + + // TODO: probably we should respect 'remember' user's choice... + linkAccount(ctx, user, *gothUser, true) + return false // user is already created here, all redirects are handled + } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { + showLinkingLogin(ctx, *gothUser) + return false // user will be created only after linking login + } + } + + // handle error without template + if len(tpl) == 0 { + ctx.ServerError("CreateUser", err) + return false + } + + // handle error with template + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tpl, form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tpl, form) + case user_model.IsErrEmailCharIsNotSupported(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form) + case user_model.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(db.ErrNameCharsNotAllowed).Name), tpl, form) + default: + ctx.ServerError("CreateUser", err) + } + return false + } + log.Trace("Account created: %s", u.Name) + return true +} + +// handleUserCreated does additional steps after a new user is created. +// It auto-sets admin for the only user, updates the optional external user and +// sends a confirmation email if required. +func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { + // Auto-set admin for the only user. + if user_model.CountUsers(ctx, nil) == 1 { + opts := &user_service.UpdateOptions{ + IsActive: optional.Some(true), + IsAdmin: optional.Some(true), + SetLastLogin: true, + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return false + } + } + + notify_service.NewUserSignUp(ctx, u) + // update external user information + if gothUser != nil { + if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil { + if !errors.Is(err, util.ErrNotExist) { + log.Error("UpdateExternalUser failed: %v", err) + } + } + } + + // Send confirmation email + if !u.IsActive && u.ID > 1 { + if setting.Service.RegisterManualConfirm { + ctx.Data["ManualActivationOnly"] = true + ctx.HTML(http.StatusOK, TplActivate) + return false + } + + if err := mailer.SendActivateAccountMail(ctx, u); err != nil { + ctx.ServerError("SendActivateAccountMail", err) + return false + } + + ctx.Data["IsSendRegisterMail"] = true + ctx.Data["Email"] = u.Email + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) + 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 false + } + + return true +} + +// Activate render activate user page +func Activate(ctx *context.Context) { + code := ctx.FormString("code") + + if len(code) == 0 { + ctx.Data["IsActivatePage"] = true + if ctx.Doer == nil || ctx.Doer.IsActive { + ctx.NotFound("invalid user", nil) + return + } + // Resend confirmation email. + if setting.Service.RegisterEmailConfirm { + var cacheKey string + if ctx.Cache.IsExist("MailChangedJustNow_" + ctx.Doer.LowerName) { + cacheKey = "MailChangedLimit_" + if err := ctx.Cache.Delete("MailChangedJustNow_" + ctx.Doer.LowerName); err != nil { + log.Error("Delete cache(MailChangedJustNow) fail: %v", err) + } + } else { + cacheKey = "MailResendLimit_" + } + if ctx.Cache.IsExist(cacheKey + ctx.Doer.LowerName) { + ctx.Data["ResendLimited"] = true + } else { + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) + if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendActivateAccountMail", err) + return + } + + if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + } + } else { + ctx.Data["ServiceNotEnabled"] = true + } + ctx.HTML(http.StatusOK, TplActivate) + return + } + + user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, false) + if err != nil { + ctx.ServerError("VerifyUserAuthorizationToken", err) + return + } + + // if code is wrong + if user == nil { + ctx.Data["IsCodeInvalid"] = 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.FormString("code") + if len(code) == 0 { + email := ctx.FormString("email") + if len(email) > 0 { + ctx.Data["IsActivatePage"] = true + if ctx.Doer == nil || ctx.Doer.IsActive { + ctx.NotFound("invalid user", nil) + return + } + // Change the primary email + if setting.Service.RegisterEmailConfirm { + if ctx.Cache.IsExist("MailChangeLimit_" + ctx.Doer.LowerName) { + ctx.Data["ResendLimited"] = true + } else { + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) + err := user_service.ReplaceInactivePrimaryEmail(ctx, ctx.Doer.Email, &user_model.EmailAddress{ + UID: ctx.Doer.ID, + Email: email, + }) + if err != nil { + ctx.Data["IsActivatePage"] = false + log.Error("Couldn't replace inactive primary email of user %d: %v", ctx.Doer.ID, err) + ctx.RenderWithErr(ctx.Tr("auth.change_unconfirmed_email_error", err), TplActivate, nil) + return + } + if err := ctx.Cache.Put("MailChangeLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailChangeLimit) fail: %v", err) + } + if err := ctx.Cache.Put("MailChangedJustNow_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailChangedJustNow) fail: %v", err) + } + + // Confirmation mail will be re-sent after the redirect to `/user/activate` below. + } + } else { + ctx.Data["ServiceNotEnabled"] = true + } + } + + ctx.Redirect(setting.AppSubURL + "/user/activate") + return + } + + user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, true) + if err != nil { + ctx.ServerError("VerifyUserAuthorizationToken", err) + return + } + + // if code is wrong + if user == nil { + ctx.Data["IsCodeInvalid"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + // if account is local account, verify password + if user.LoginSource == 0 { + password := ctx.FormString("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["IsPasswordInvalid"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + } + + handleAccountActivation(ctx, user) +} + +func handleAccountActivation(ctx *context.Context, user *user_model.User) { + user.IsActive = true + var err error + if user.Rands, err = user_model.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := user_model.UpdateUserCols(ctx, user, "is_active", "rands"); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("UpdateUserCols", err) + } else { + ctx.ServerError("UpdateUser", err) + } + return + } + + if err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true); err != nil { + log.Error("Unable to activate email for user: %-v with email: %s: %v", user, user.Email, err) + ctx.ServerError("ActivateUserEmail", err) + return + } + + log.Trace("User activated: %s", user.Name) + + if err := updateSession(ctx, nil, map[string]any{ + "uid": user.ID, + }); err != nil { + log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err) + ctx.ServerError("ActivateUserEmail", err) + return + } + + if err := resetLocale(ctx, user); err != nil { + ctx.ServerError("resetLocale", err) + return + } + + if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("auth.account_activated")) + if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") +} + +// ActivateEmail render the activate email page +func ActivateEmail(ctx *context.Context) { + code := ctx.FormString("code") + emailStr := ctx.FormString("email") + + u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr), true) + if err != nil { + ctx.ServerError("VerifyUserAuthorizationToken", err) + return + } + if u == nil { + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID) + if err != nil { + ctx.ServerError("GetEmailAddressOfUser", err) + return + } + + if err := user_model.ActivateEmail(ctx, email); err != nil { + ctx.ServerError("ActivateEmail", err) + return + } + + log.Trace("Email activated: %s", email.Email) + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + + // Allow user to validate more emails + _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) + + // FIXME: e-mail verification does not require the user to be logged in, + // so this could be redirecting to the login page. + // Should users be logged in automatically here? (consider 2FA requirements, etc.) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +func updateSession(ctx *context.Context, deletes []string, updates map[string]any) error { + if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil { + return fmt.Errorf("regenerate session: %w", err) + } + sess := ctx.Session + sessID := sess.ID() + for _, k := range deletes { + if err := sess.Delete(k); err != nil { + return fmt.Errorf("delete %v in session[%s]: %w", k, sessID, err) + } + } + for k, v := range updates { + if err := sess.Set(k, v); err != nil { + return fmt.Errorf("set %v in session[%s]: %w", k, sessID, err) + } + } + if err := sess.Release(); err != nil { + return fmt.Errorf("store session[%s]: %w", sessID, err) + } + return nil +} diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go new file mode 100644 index 0000000..c6afbf8 --- /dev/null +++ b/routers/web/auth/auth_test.go @@ -0,0 +1,43 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestUserLogin(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "/user/login") + SignIn(ctx) + assert.Equal(t, http.StatusOK, resp.Code) + + ctx, resp = contexttest.MockContext(t, "/user/login") + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, http.StatusSeeOther, resp.Code) + assert.Equal(t, "/", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to=/other") + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/other", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login") + ctx.Req.AddCookie(&http.Cookie{Name: "redirect_to", Value: "/other-cookie"}) + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/other-cookie", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to="+url.QueryEscape("https://example.com")) + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/", test.RedirectURL(resp)) +} diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go new file mode 100644 index 0000000..9b0141c --- /dev/null +++ b/routers/web/auth/linkaccount.go @@ -0,0 +1,308 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + + "github.com/markbates/goth" +) + +var tplLinkAccount base.TplName = "user/auth/link_account" + +// 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["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + 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, err := getUserName(&gu) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + email := gu.Email + ctx.Data["user_name"] = uname + ctx.Data["email"] = email + + if len(email) != 0 { + u, err := user_model.GetUserByEmail(ctx, email) + if err != nil && !user_model.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } else if len(uname) != 0 { + u, err := user_model.GetUserByName(ctx, uname) + if err != nil && !user_model.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } + + ctx.HTML(http.StatusOK, tplLinkAccount) +} + +func handleSignInError(ctx *context.Context, userName string, ptrForm any, tmpl base.TplName, invoker string, err error) { + if errors.Is(err, util.ErrNotExist) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm) + } else if errors.Is(err, util.ErrInvalidArgument) { + ctx.Data["user_exists"] = true + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm) + } else if user_model.IsErrUserProhibitLogin(err) { + ctx.Data["user_exists"] = true + log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if user_model.IsErrUserInactive(err) { + ctx.Data["user_exists"] = true + 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", userName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } + } else { + ctx.ServerError(invoker, err) + } +} + +// LinkAccountPostSignIn handle the coupling of external account with another account using signIn +func LinkAccountPostSignIn(ctx *context.Context) { + signInForm := web.GetForm(ctx).(*forms.SignInForm) + ctx.Data["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["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + 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 := auth_service.UserSignIn(ctx, signInForm.UserName, signInForm.Password) + if err != nil { + handleSignInError(ctx, signInForm.UserName, &signInForm, tplLinkAccount, "UserLinkAccount", err) + return + } + + linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) +} + +func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) { + updateAvatarIfNeed(ctx, 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. + // We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here + _, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil { + if !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserLinkAccount", err) + return + } + + err = externalaccount.LinkAccountToUser(ctx, u, gothUser) + if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + + handleSignIn(ctx, u, remember) + return + } + + if err := updateSession(ctx, nil, map[string]any{ + // User needs to use 2FA, save data and redirect to 2FA page. + "twofaUid": u.ID, + "twofaRemember": remember, + "linkAccount": true, + }); err != nil { + ctx.ServerError("RegenerateSession", err) + return + } + + // If WebAuthn is enrolled -> Redirect to WebAuthn instead + regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/webauthn") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// LinkAccountPostRegister handle the creation of a new account for an external account using signUp +func LinkAccountPostRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RegisterForm) + // 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["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + 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 { + context.VerifyCaptcha(ctx, tplLinkAccount, form) + if ctx.Written() { + return + } + } + + if !form.IsEmailDomainAllowed() { + ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form) + return + } + + if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword { + // In user_model.User an empty password is classed as not set, so we set form.Password to empty. + // Eventually the database should be changed to indicate "Second Factor"-enabled accounts + // (accounts that do not introduce the security vulnerabilities of a password). + // If a user decides to circumvent second-factor security, and purposefully create a password, + // they can still do so using the "Recover Account" option. + form.Password = "" + } else { + if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.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 + } + } + + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) + if err != nil { + ctx.ServerError("CreateUser", err) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + LoginName: gothUser.UserID, + } + + if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) { + // error already handled + return + } + + source := authSource.Cfg.(*oauth2.Source) + if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + + handleSignIn(ctx, u, false) +} diff --git a/routers/web/auth/main_test.go b/routers/web/auth/main_test.go new file mode 100644 index 0000000..b438e5d --- /dev/null +++ b/routers/web/auth/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go new file mode 100644 index 0000000..8705d1b --- /dev/null +++ b/routers/web/auth/oauth.go @@ -0,0 +1,1427 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + go_context "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "html" + "html/template" + "io" + "net/http" + "net/url" + "sort" + "strings" + + "code.gitea.io/gitea/models/auth" + org_model "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + auth_service "code.gitea.io/gitea/services/auth" + source_service "code.gitea.io/gitea/services/auth/source" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + remote_service "code.gitea.io/gitea/services/remote" + user_service "code.gitea.io/gitea/services/user" + + "gitea.com/go-chi/binding" + "github.com/golang-jwt/jwt/v5" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/fitbit" + "github.com/markbates/goth/providers/openidConnect" + "github.com/markbates/goth/providers/zoom" + go_oauth2 "golang.org/x/oauth2" +) + +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 +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1 +type AuthorizeErrorCode string + +const ( + // ErrorCodeInvalidRequest represents the according error in RFC 6749 + ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request" + // ErrorCodeUnauthorizedClient represents the according error in RFC 6749 + ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client" + // ErrorCodeAccessDenied represents the according error in RFC 6749 + ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied" + // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749 + ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type" + // ErrorCodeInvalidScope represents the according error in RFC 6749 + ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope" + // ErrorCodeServerError represents the according error in RFC 6749 + ErrorCodeServerError AuthorizeErrorCode = "server_error" + // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749 + ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable" +) + +// AuthorizeError represents an error type specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1 +type AuthorizeError struct { + ErrorCode AuthorizeErrorCode `json:"error" form:"error"` + ErrorDescription string + State string +} + +// Error returns the error message +func (err AuthorizeError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// AccessTokenErrorCode represents an error code specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +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 +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +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) +} + +// errCallback represents a oauth2 callback error +type errCallback struct { + Code string + Description string +} + +func (err errCallback) Error() string { + return err.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 +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2 +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(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) { + if setting.OAuth2.InvalidateRefreshTokens { + if err := grant.IncreaseCounter(ctx); 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 := &oauth2.Token{ + GrantID: grant.ID, + Type: oauth2.TypeAccessToken, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()), + }, + } + signedAccessToken, err := accessToken.SignToken(serverKey) + 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() + refreshToken := &oauth2.Token{ + GrantID: grant.ID, + Counter: grant.Counter, + Type: oauth2.TypeRefreshToken, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExpirationDate), + }, + } + signedRefreshToken, err := refreshToken.SignToken(serverKey) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate OpenID Connect id_token + signedIDToken := "" + if grant.ScopeContains("openid") { + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find application", + } + } + user, err := user_model.GetUserByID(ctx, grant.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find user", + } + } + log.Error("Error loading user: %v", err) + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "server error", + } + } + + idToken := &oauth2.OIDCToken{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()), + Issuer: setting.AppURL, + Audience: []string{app.ClientID}, + Subject: fmt.Sprint(grant.UserID), + }, + Nonce: grant.Nonce, + } + if grant.ScopeContains("profile") { + idToken.Name = user.GetDisplayName() + idToken.PreferredUsername = user.Name + idToken.Profile = user.HTMLURL() + idToken.Picture = user.AvatarLink(ctx) + idToken.Website = user.Website + idToken.Locale = user.Language + idToken.UpdatedAt = user.UpdatedUnix + } + if grant.ScopeContains("email") { + idToken.Email = user.Email + idToken.EmailVerified = user.IsActive + } + if grant.ScopeContains("groups") { + onlyPublicGroups := ifOnlyPublicGroups(grant.Scope) + + groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups) + if err != nil { + log.Error("Error getting groups: %v", err) + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "server error", + } + } + idToken.Groups = groups + } + + signedIDToken, err = idToken.SignToken(clientKey) + 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"` + Groups []string `json:"groups,omitempty"` +} + +func ifOnlyPublicGroups(scopes string) bool { + scopes = strings.ReplaceAll(scopes, ",", " ") + scopesList := strings.Fields(scopes) + for _, scope := range scopesList { + if scope == "all" || scope == "read:organization" || scope == "read:admin" { + return false + } + } + return true +} + +// InfoOAuth manages request for userinfo endpoint +func InfoOAuth(ctx *context.Context) { + if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() { + ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`) + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + response := &userInfoResponse{ + Sub: fmt.Sprint(ctx.Doer.ID), + Name: ctx.Doer.FullName, + Username: ctx.Doer.Name, + Email: ctx.Doer.Email, + Picture: ctx.Doer.AvatarLink(ctx), + } + + var token string + if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" { + auths := strings.Fields(auHead) + if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { + token = auths[1] + } + } + + _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token) + onlyPublicGroups := ifOnlyPublicGroups(grantScopes) + + groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups) + if err != nil { + ctx.ServerError("Oauth groups for user", err) + return + } + response.Groups = groups + + ctx.JSON(http.StatusOK, response) +} + +// returns a list of "org" and "org:team" strings, +// that the given user is a part of. +func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) { + orgs, err := org_model.GetUserOrgsList(ctx, user) + if err != nil { + return nil, fmt.Errorf("GetUserOrgList: %w", err) + } + + var groups []string + for _, org := range orgs { + if setting.OAuth2.EnableAdditionalGrantScopes { + if onlyPublicGroups { + public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID) + if !public && err == nil { + continue + } + } + } + + groups = append(groups, org.Name) + teams, err := org.LoadTeams(ctx) + if err != nil { + return nil, fmt.Errorf("LoadTeams: %w", err) + } + for _, team := range teams { + if team.IsMember(ctx, user.ID) { + groups = append(groups, org.Name+":"+team.LowerName) + } + } + } + return groups, nil +} + +func parseBasicAuth(ctx *context.Context) (username, password string, err error) { + authHeader := ctx.Req.Header.Get("Authorization") + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { + return base.BasicAuthDecode(authData) + } + return "", "", errors.New("invalid basic authentication") +} + +// IntrospectOAuth introspects an oauth token +func IntrospectOAuth(ctx *context.Context) { + clientIDValid := false + if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID) + if err != nil && !auth.IsErrOauthClientIDInvalid(err) { + // this is likely a database error; log it and respond without details + log.Error("Error retrieving client_id: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret)) + } + if !clientIDValid { + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`) + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + var response struct { + Active bool `json:"active"` + Scope string `json:"scope,omitempty"` + Username string `json:"username,omitempty"` + jwt.RegisteredClaims + } + + form := web.GetForm(ctx).(*forms.IntrospectTokenForm) + token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey) + if err == nil { + grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID) + if err == nil && grant != nil { + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err == nil && app != nil { + response.Active = true + response.Scope = grant.Scope + response.Issuer = setting.AppURL + response.Audience = []string{app.ClientID} + response.Subject = fmt.Sprint(grant.UserID) + } + if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil { + response.Username = user.Name + } + } + } + + ctx.JSON(http.StatusOK, response) +} + +// AuthorizeOAuth manages authorize requests +func AuthorizeOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AuthorizationForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + if auth.IsErrOauthClientIDInvalid(err) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnauthorizedClient, + ErrorDescription: "Client ID not registered", + State: form.State, + }, "") + return + } + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + + var user *user_model.User + if app.UID != 0 { + user, err = user_model.GetUserByID(ctx, app.UID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + } + + if !app.ContainsRedirectURI(form.RedirectURI) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "Unregistered Redirect URI", + State: form.State, + }, "") + return + } + + if form.ResponseType != "code" { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnsupportedResponseType, + ErrorDescription: "Only code response type is supported.", + State: form.State, + }, form.RedirectURI) + return + } + + // pkce support + switch form.CodeChallengeMethod { + case "S256": + 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 "": + // "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message" + // https://datatracker.ietf.org/doc/html/rfc8252#section-8.1 + if !app.ConfidentialClient { + // "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request"" + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1 + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "PKCE is required for public clients", + State: form.State, + }, form.RedirectURI) + return + } + default: + // "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"." + // https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1 + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "unsupported code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + + grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + + // Redirect if user already granted access and the application is confidential. + // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2 + if app.ConfidentialClient && grant != nil { + code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + // Update nonce to reflect the new session + if len(form.Nonce) > 0 { + err := grant.SetNonce(ctx, form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + ctx.Redirect(redirect.String()) + return + } + + // show authorize page to grant access + ctx.Data["Application"] = app + ctx.Data["RedirectURI"] = form.RedirectURI + ctx.Data["State"] = form.State + ctx.Data["Scope"] = form.Scope + ctx.Data["Nonce"] = form.Nonce + if user != nil { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name))) + } else { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName))) + } + ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("<strong>" + html.EscapeString(form.RedirectURI) + "</strong>") + // TODO document SESSION <=> FORM + err = ctx.Session.Set("client_id", app.ClientID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("redirect_uri", form.RedirectURI) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("state", form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + 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 + } + + if !form.Granted { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "the request is denied", + ErrorCode: ErrorCodeAccessDenied, + }, form.RedirectURI) + return + } + + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + if grant == nil { + grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope) + if err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "cannot create grant for user", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + } else if grant.Scope != form.Scope { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "a grant exists with different scope", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + + if len(form.Nonce) > 0 { + err := grant.SetNonce(ctx, form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + + var codeChallenge, codeChallengeMethod string + codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string) + codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string) + + code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + ctx.Redirect(redirect.String(), http.StatusSeeOther) +} + +// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities +func OIDCWellKnown(ctx *context.Context) { + ctx.Data["SigningKey"] = oauth2.DefaultSigningKey + ctx.JSONTemplate("user/auth/oidc_wellknown") +} + +// OIDCKeys generates the JSON Web Key Set +func OIDCKeys(ctx *context.Context) { + jwk, err := oauth2.DefaultSigningKey.ToJWK() + if err != nil { + log.Error("Error converting signing key to JWK: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + + jwk["use"] = "sig" + + jwks := map[string][]map[string]string{ + "keys": { + jwk, + }, + } + + ctx.Resp.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(ctx.Resp) + if err := enc.Encode(jwks); err != nil { + log.Error("Failed to encode representation as json. Error: %v", err) + } +} + +// AccessTokenOAuth manages all access token requests by the client +func AccessTokenOAuth(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AccessTokenForm) + // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header + if form.ClientID == "" || form.ClientSecret == "" { + authHeader := ctx.Req.Header.Get("Authorization") + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { + clientID, clientSecret, err := base.BasicAuthDecode(authData) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot parse basic auth header", + }) + return + } + // validate that any fields present in the form match the Basic auth header + if form.ClientID != "" && form.ClientID != clientID { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "client_id in request body inconsistent with Authorization header", + }) + return + } + form.ClientID = clientID + if form.ClientSecret != "" && form.ClientSecret != clientSecret { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "client_secret in request body inconsistent with Authorization header", + }) + return + } + form.ClientSecret = clientSecret + } + } + + serverKey := oauth2.DefaultSigningKey + clientKey := serverKey + if serverKey.IsSymmetric() { + var err error + clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret)) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "Error creating signing key", + }) + return + } + } + + switch form.GrantType { + case "refresh_token": + handleRefreshToken(ctx, form, serverKey, clientKey) + case "authorization_code": + handleAuthorizationCode(ctx, form, serverKey, clientKey) + default: + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnsupportedGrantType, + ErrorDescription: "Only refresh_token or authorization_code grant type is supported", + }) + } +} + +func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID), + }) + return + } + // "The authorization server MUST ... require client authentication for confidential clients" + // https://datatracker.ietf.org/doc/html/rfc6749#section-6 + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { + errorDescription := "invalid client secret" + if form.ClientSecret == "" { + errorDescription = "invalid empty client secret" + } + // "invalid_client ... Client authentication failed" + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: errorDescription, + }) + return + } + + token, err := oauth2.ParseToken(form.RefreshToken, serverKey) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "unable to parse refresh token", + }) + return + } + // get grant before increasing counter + grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID) + if err != nil || grant == nil { + handleAccessTokenError(ctx, 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(ctx, grant, serverKey, clientKey) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + ctx.JSON(http.StatusOK, accessToken) +} + +func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, 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.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { + errorDescription := "invalid client secret" + if form.ClientSecret == "" { + errorDescription = "invalid empty client secret" + } + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: errorDescription, + }) + return + } + if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "unexpected redirect URI", + }) + return + } + authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, 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: "failed PKCE code challenge", + }) + 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(ctx); err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot proceed your request", + }) + } + resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + // send successful response + ctx.JSON(http.StatusOK, resp) +} + +func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) { + ctx.JSON(http.StatusBadRequest, acErr) +} + +func handleServerError(ctx *context.Context, state, redirectURI string, err error) { + log.Error("OAuth server error: %v", err) + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "A server error occurred", + State: state, + }, redirectURI) +} + +func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) { + if redirectURI == "" { + log.Warn("Authorization failed: %v", authErr.ErrorDescription) + ctx.Data["Error"] = authErr + ctx.HTML(http.StatusBadRequest, tplGrantError) + return + } + redirect, err := url.Parse(redirectURI) + if err != nil { + ctx.ServerError("url.Parse", err) + return + } + q := redirect.Query() + q.Set("error", string(authErr.ErrorCode)) + q.Set("error_description", authErr.ErrorDescription) + q.Set("state", authErr.State) + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String(), http.StatusSeeOther) +} + +// SignInOAuth handles the OAuth2 login buttons +func SignInOAuth(ctx *context.Context) { + provider := ctx.Params(":provider") + + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user + user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) + if err == nil && user != nil { + // we got the user without going through the whole OAuth2 authentication flow again + handleOAuth2SignIn(ctx, authSource, user, gothUser) + return + } + + codeChallenge, err := generateCodeChallenge(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err)) + return + } + + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { + if strings.Contains(err.Error(), "no provider for ") { + if err = oauth2.ResetOAuth2(ctx); err != nil { + ctx.ServerError("SignIn", err) + return + } + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); 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") + + if ctx.Req.FormValue("error") != "" { + var errorKeyValues []string + for k, vv := range ctx.Req.Form { + for _, v := range vv { + errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v))) + } + } + sort.Strings(errorKeyValues) + ctx.Flash.Error(strings.Join(errorKeyValues, "<br>"), true) + } + + // first look if the provider is still active + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + if authSource == nil { + ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider")) + return + } + + u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) + if err != nil { + if user_model.IsErrUserProhibitLogin(err) { + uplerr := err.(user_model.ErrUserProhibitLogin) + log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + return + } + if callbackErr, ok := err.(errCallback); ok { + log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description) + switch callbackErr.Code { + case "access_denied": + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied")) + case "temporarily_unavailable": + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable")) + default: + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error")) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + if err, ok := err.(*go_oauth2.RetrieveError); ok { + ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true) + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + ctx.ServerError("UserSignIn", err) + return + } + + if u == nil { + if ctx.Doer != nil { + // attach user to already logged in user + err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) + if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration { + // create new user with details from oauth2 provider + if gothUser.UserID == "" { + log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name) + if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).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 field: UserID", authSource.Name) + ctx.ServerError("CreateUser", err) + return + } + var missingFields []string + if gothUser.Email == "" { + missingFields = append(missingFields, "email") + } + if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" { + missingFields = append(missingFields, "nickname") + } + if len(missingFields) > 0 { + // we don't have enough information to create an account automatically, + // so we prompt the user for the remaining bits + log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields) + showLinkingLogin(ctx, gothUser) + return + } + uname, err := getUserName(&gothUser) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + u = &user_model.User{ + Name: uname, + FullName: gothUser.Name, + Email: gothUser.Email, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + LoginName: gothUser.UserID, + } + + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm), + } + + source := authSource.Cfg.(*oauth2.Source) + + isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser) + u.IsAdmin = isAdmin.ValueOrDefault(false) + u.IsRestricted = isRestricted.ValueOrDefault(false) + + if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { + // error already handled + return + } + + if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } else { + // no existing user is found, request attach or new account + showLinkingLogin(ctx, gothUser) + return + } + } + + handleOAuth2SignIn(ctx, authSource, u, gothUser) +} + +func claimValueToStringSet(claimValue any) container.Set[string] { + var groups []string + + switch rawGroup := claimValue.(type) { + case []string: + groups = rawGroup + case []any: + for _, group := range rawGroup { + groups = append(groups, fmt.Sprintf("%s", group)) + } + default: + str := fmt.Sprintf("%s", rawGroup) + groups = strings.Split(str, ",") + } + return container.SetOf(groups...) +} + +func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { + if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return err + } + + groups := getClaimedGroups(source, gothUser) + + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + return err + } + } + + return nil +} + +func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] { + groupClaims, has := gothUser.RawData[source.GroupClaimName] + if !has { + return nil + } + + return claimValueToStringSet(groupClaims) +} + +func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) { + groups := getClaimedGroups(source, gothUser) + + if source.AdminGroup != "" { + isAdmin = optional.Some(groups.Contains(source.AdminGroup)) + } + if source.RestrictedGroup != "" { + isRestricted = optional.Some(groups.Contains(source.RestrictedGroup)) + } + + return isAdmin, isRestricted +} + +func showLinkingLogin(ctx *context.Context, gothUser goth.User) { + if err := updateSession(ctx, nil, map[string]any{ + "linkAccountGothUser": gothUser, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + ctx.Redirect(setting.AppSubURL + "/user/link_account") +} + +func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.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 := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1)) + if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize { + _ = user_service.UploadAvatar(ctx, u, data) + } + } + } +} + +func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { + updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) + + needs2FA := false + if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { + _, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserSignIn", err) + return + } + needs2FA = err == nil + } + + oauth2Source := source.Cfg.(*oauth2.Source) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap) + if err != nil { + ctx.ServerError("UnmarshalGroupTeamMapping", err) + return + } + + groups := getClaimedGroups(oauth2Source, &gothUser) + + // If this user is enrolled in 2FA and this source doesn't override it, + // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. + if !needs2FA { + if err := updateSession(ctx, nil, map[string]any{ + "uid": u.ID, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + // Clear whatever CSRF cookie has right now, force to generate a new one + ctx.Csrf.DeleteCookie(ctx) + + opts := &user_service.UpdateOptions{ + SetLastLogin: true, + } + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + + // update external user information + if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { + if !errors.Is(err, util.ErrNotExist) { + log.Error("UpdateExternalUser failed: %v", err) + } + } + + if err := resetLocale(ctx, u); err != nil { + ctx.ServerError("resetLocale", err) + return + } + + if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") + return + } + + opts := &user_service.UpdateOptions{} + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + if opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + } + + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + + if err := updateSession(ctx, nil, map[string]any{ + // User needs to use 2FA, save data and redirect to 2FA page. + "twofaUid": u.ID, + "twofaRemember": false, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + // If WebAuthn is enrolled -> Redirect to WebAuthn instead + regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/webauthn") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE +func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) { + // the `code_verifier` is only forwarded by specific providers + // https://codeberg.org/forgejo/forgejo/issues/4033 + p, ok := goth.GetProviders()[provider] + if !ok { + return "", nil + } + switch p.(type) { + default: + return "", nil + case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider: + // those providers forward the `code_verifier` + // a code_challenge can be generated + } + + codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness) + if err != nil { + return "", err + } + if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil { + return "", err + } + return encodeCodeChallenge(codeVerifier) +} + +func encodeCodeChallenge(codeVerifier string) (string, error) { + hasher := sha256.New() + _, err := io.WriteString(hasher, codeVerifier) + codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + return codeChallenge, err +} + +// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful +// login the user +func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { + gothUser, err := oAuth2FetchUser(ctx, authSource, request, response) + if err != nil { + return nil, goth.User{}, err + } + + if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil { + return nil, goth.User{}, err + } + + u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser) + return u, gothUser, err +} + +func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) { + oauth2Source := authSource.Cfg.(*oauth2.Source) + + // Make sure that the response is not an error response. + errorName := request.FormValue("error") + + if len(errorName) > 0 { + errorDescription := request.FormValue("error_description") + + // Delete the goth session + err := gothic.Logout(response, request) + if err != nil { + return goth.User{}, err + } + + return goth.User{}, errCallback{ + Code: errorName, + Description: errorDescription, + } + } + + // Proceed to authenticate through goth. + codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string) + _ = ctx.Session.Delete("CodeVerifier") + gothUser, err := oauth2Source.Callback(request, response, codeVerifier) + if err != nil { + if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data 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", authSource.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", authSource.Name, setting.OAuth2.MaxTokenLength) + } + return goth.User{}, err + } + + if oauth2Source.RequiredClaimName != "" { + claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] + if !has { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + } + + if oauth2Source.RequiredClaimValue != "" { + groups := claimValueToStringSet(claimInterface) + + if !groups.Contains(oauth2Source.RequiredClaimValue) { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + } + } + } + + return gothUser, nil +} + +func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) { + user := &user_model.User{ + LoginName: gothUser.UserID, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + } + + hasUser, err := user_model.GetUser(ctx, user) + if err != nil { + return nil, err + } + + if hasUser { + return user, nil + } + log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType) + + // search in external linked users + externalLoginUser := &user_model.ExternalLoginUser{ + ExternalID: gothUser.UserID, + LoginSourceID: authSource.ID, + } + hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser) + if err != nil { + return nil, err + } + if hasUser { + user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID) + return user, err + } + + // no user found to login + return nil, nil +} diff --git a/routers/web/auth/oauth_test.go b/routers/web/auth/oauth_test.go new file mode 100644 index 0000000..5a4a646 --- /dev/null +++ b/routers/web/auth/oauth_test.go @@ -0,0 +1,103 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/auth/source/oauth2" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToken { + signingKey, err := oauth2.CreateJWTSigningKey("HS256", make([]byte, 32)) + require.NoError(t, err) + assert.NotNil(t, signingKey) + + response, terr := newAccessTokenResponse(db.DefaultContext, grant, signingKey, signingKey) + assert.Nil(t, terr) + assert.NotNil(t, response) + + parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2.OIDCToken{}, func(token *jwt.Token) (any, error) { + assert.NotNil(t, token.Method) + assert.Equal(t, signingKey.SigningMethod().Alg(), token.Method.Alg()) + return signingKey.VerifyKey(), nil + }) + require.NoError(t, err) + assert.True(t, parsedToken.Valid) + + oidcToken, ok := parsedToken.Claims.(*oauth2.OIDCToken) + assert.True(t, ok) + assert.NotNil(t, oidcToken) + + return oidcToken +} + +func TestNewAccessTokenResponse_OIDCToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + grants, err := auth.GetOAuth2GrantsByUserID(db.DefaultContext, 3) + require.NoError(t, err) + assert.Len(t, grants, 1) + + // Scopes: openid + oidcToken := createAndParseToken(t, grants[0]) + assert.Empty(t, oidcToken.Name) + assert.Empty(t, oidcToken.PreferredUsername) + assert.Empty(t, oidcToken.Profile) + assert.Empty(t, oidcToken.Picture) + assert.Empty(t, oidcToken.Website) + assert.Empty(t, oidcToken.UpdatedAt) + assert.Empty(t, oidcToken.Email) + assert.False(t, oidcToken.EmailVerified) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + grants, err = auth.GetOAuth2GrantsByUserID(db.DefaultContext, user.ID) + require.NoError(t, err) + assert.Len(t, grants, 1) + + // Scopes: openid profile email + oidcToken = createAndParseToken(t, grants[0]) + assert.Equal(t, user.Name, oidcToken.Name) + assert.Equal(t, user.Name, oidcToken.PreferredUsername) + assert.Equal(t, user.HTMLURL(), oidcToken.Profile) + assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture) + assert.Equal(t, user.Website, oidcToken.Website) + assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt) + assert.Equal(t, user.Email, oidcToken.Email) + assert.Equal(t, user.IsActive, oidcToken.EmailVerified) + + // set DefaultShowFullName to true + oldDefaultShowFullName := setting.UI.DefaultShowFullName + setting.UI.DefaultShowFullName = true + defer func() { + setting.UI.DefaultShowFullName = oldDefaultShowFullName + }() + + // Scopes: openid profile email + oidcToken = createAndParseToken(t, grants[0]) + assert.Equal(t, user.FullName, oidcToken.Name) + assert.Equal(t, user.Name, oidcToken.PreferredUsername) + assert.Equal(t, user.HTMLURL(), oidcToken.Profile) + assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture) + assert.Equal(t, user.Website, oidcToken.Website) + assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt) + assert.Equal(t, user.Email, oidcToken.Email) + assert.Equal(t, user.IsActive, oidcToken.EmailVerified) +} + +func TestEncodeCodeChallenge(t *testing.T) { + // test vector from https://datatracker.ietf.org/doc/html/rfc7636#page-18 + codeChallenge, err := encodeCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + require.NoError(t, err) + assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", codeChallenge) +} diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go new file mode 100644 index 0000000..83268fa --- /dev/null +++ b/routers/web/auth/openid.go @@ -0,0 +1,391 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "net/http" + "net/url" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" + "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.FormString("openid.return_to") != "" { + signInOpenIDVerify(ctx) + return + } + + if CheckAutoLogin(ctx) { + return + } + + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + ctx.HTML(http.StatusOK, tplSignInOpenID) +} + +// Check if the given OpenID URI is allowed by blacklist/whitelist +func allowedOpenIDURI(uri string) (err error) { + // In case a Whitelist is present, URI must be in it + // in order to be accepted + if len(setting.Service.OpenIDWhitelist) != 0 { + for _, pat := range setting.Service.OpenIDWhitelist { + if pat.MatchString(uri) { + return nil // pass + } + } + // must match one of this or be refused + return 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: %s", ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: %s", fullURL) + + id, err := openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: %s", id) + + /* Now we should seek for the user and log him in, or prompt + * to register if not found */ + + u, err := user_model.GetUserByOpenID(ctx, id) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.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: %s does not exist, should connect or register", id) + + 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=%s and nickname=%s", email, nickname) + + if email != "" { + u, err = user_model.GetUserByEmail(ctx, email) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + log.Error("signInOpenIDVerify: %v", err) + } + if u != nil { + log.Trace("Local user %s has OpenID provided email %s", u.LowerName, email) + } + } + + if u == nil && nickname != "" { + u, _ = user_model.GetUserByName(ctx, nickname) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + } + if u != nil { + log.Trace("Local user %s has OpenID provided nickname %s", u.LowerName, nickname) + } + } + + if u != nil { + nickname = u.LowerName + } + if err := updateSession(ctx, nil, map[string]any{ + "openid_verified_uri": id, + "openid_determined_email": email, + "openid_determined_username": nickname, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + if u != nil || !setting.Service.EnableOpenIDSignUp || setting.Service.AllowOnlyInternalRegistration { + ctx.Redirect(setting.AppSubURL + "/user/openid/connect") + } else { + ctx.Redirect(setting.AppSubURL + "/user/openid/register") + } +} + +// 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 := auth.UserSignIn(ctx, form.UserName, form.Password) + if err != nil { + handleSignInError(ctx, form.UserName, &form, tplConnectOID, "ConnectOpenIDPost", err) + return + } + + // add OpenID for the user + userOID := &user_model.UserOpenID{UID: u.ID, URI: oid} + if err = user_model.AddUserOpenID(ctx, userOID); err != nil { + if user_model.IsErrOpenIDAlreadyUsed(err) { + ctx.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["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + 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 + context.SetCaptchaData(ctx) + ctx.Data["OpenID"] = oid + + if setting.Service.AllowOnlyInternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if setting.Service.EnableCaptcha { + if err := ctx.Req.ParseForm(); err != nil { + ctx.ServerError("", err) + return + } + context.VerifyCaptcha(ctx, tplSignUpOID, form) + } + + length := setting.MinPasswordLength + if length < 256 { + length = 256 + } + password, err := util.CryptoRandomString(int64(length)) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignUpOID, form) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: password, + } + if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) { + // error already handled + return + } + + // add OpenID for the user + userOID := &user_model.UserOpenID{UID: u.ID, URI: oid} + if err = user_model.AddUserOpenID(ctx, userOID); err != nil { + if user_model.IsErrOpenIDAlreadyUsed(err) { + ctx.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/auth/password.go b/routers/web/auth/password.go new file mode 100644 index 0000000..363c01c --- /dev/null +++ b/routers/web/auth/password.go @@ -0,0 +1,325 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" +) + +var ( + // tplMustChangePassword template for updating a user's password + tplMustChangePassword base.TplName = "user/auth/change_passwd" + tplForgotPassword base.TplName = "user/auth/forgot_passwd" + tplResetPassword base.TplName = "user/auth/reset_passwd" +) + +// ForgotPasswd render the forget password page +func ForgotPasswd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + log.Warn("no mail service configured") + ctx.Data["IsResetDisable"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + ctx.Data["Email"] = ctx.FormString("email") + + ctx.Data["IsResetRequest"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +// ForgotPasswdPost response for forget password request +func ForgotPasswdPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + ctx.NotFound("ForgotPasswdPost", nil) + return + } + ctx.Data["IsResetRequest"] = true + + email := ctx.FormString("email") + ctx.Data["Email"] = email + + u, err := user_model.GetUserByEmail(ctx, email) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + ctx.ServerError("user.ResetPasswd(check existence)", err) + return + } + + if !u.IsLocal() && !u.IsOAuth2() { + ctx.Data["Err_Email"] = true + ctx.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 + } + + if err := mailer.SendResetPasswordMail(ctx, u); err != nil { + ctx.ServerError("SendResetPasswordMail", err) + return + } + + if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +func commonResetPassword(ctx *context.Context, shouldDeleteToken bool) (*user_model.User, *auth.TwoFactor) { + code := ctx.FormString("code") + + ctx.Data["Title"] = ctx.Tr("auth.reset_password") + ctx.Data["Code"] = code + + if nil != ctx.Doer { + ctx.Data["user_signed_in"] = true + } + + if len(code) == 0 { + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) + return nil, nil + } + + // Fail early, don't frustrate the user + u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset, shouldDeleteToken) + if err != nil { + ctx.ServerError("VerifyUserAuthorizationToken", err) + return nil, nil + } + + if u == nil { + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) + return nil, nil + } + + twofa, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil { + if !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error()) + return nil, nil + } + } else { + ctx.Data["has_two_factor"] = true + ctx.Data["scratch_code"] = ctx.FormBool("scratch_code") + } + + // Show the user that they are affecting the account that they intended to + ctx.Data["user_email"] = u.Email + + if nil != ctx.Doer && u.ID != ctx.Doer.ID { + ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.Doer.Email, u.Email), true) + return nil, nil + } + + return u, twofa +} + +// ResetPasswd render the account recovery page +func ResetPasswd(ctx *context.Context) { + ctx.Data["IsResetForm"] = true + + commonResetPassword(ctx, false) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplResetPassword) +} + +// ResetPasswdPost response from account recovery request +func ResetPasswdPost(ctx *context.Context) { + u, twofa := commonResetPassword(ctx, true) + if ctx.Written() { + return + } + + if u == nil { + // Flash error has been set + ctx.HTML(http.StatusOK, tplResetPassword) + return + } + + // Handle two-factor + regenerateScratchToken := false + if twofa != nil { + if ctx.FormBool("scratch_code") { + if !twofa.VerifyScratchToken(ctx.FormString("token")) { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Token"] = true + ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplResetPassword, nil) + return + } + regenerateScratchToken = true + } else { + passcode := ctx.FormString("passcode") + ok, err := twofa.ValidateTOTP(passcode) + if err != nil { + ctx.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 = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err) + return + } + } + } + + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(ctx.FormString("password")), + MustChangePassword: optional.Some(false), + } + if err := user_service.UpdateAuth(ctx, u, opts); err != nil { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + switch { + case errors.Is(err, password.ErrMinLength): + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil) + case errors.Is(err, password.ErrComplexity): + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil) + case errors.Is(err, password.ErrIsPwned): + ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplResetPassword, nil) + case password.IsErrIsPwnedRequest(err): + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplResetPassword, nil) + default: + ctx.ServerError("UpdateAuth", err) + } + return + } + + log.Trace("User password reset: %s", u.Name) + ctx.Data["IsResetFailed"] = true + remember := len(ctx.FormString("remember")) != 0 + + if regenerateScratchToken { + // Invalidate the scratch token. + _, err := twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + if ctx.Written() { + return + } + ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + + handleSignIn(ctx, u, remember) +} + +// MustChangePassword renders the page to change a user's password +func MustChangePassword(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + ctx.Data["MustChangePassword"] = true + ctx.HTML(http.StatusOK, tplMustChangePassword) +} + +// MustChangePasswordPost response for updating a user's password after their +// account was created by an admin +func MustChangePasswordPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MustChangePasswordForm) + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMustChangePassword) + return + } + + // Make sure only requests for users who are eligible to change their password via + // this method passes through + if !ctx.Doer.MustChangePassword { + ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page")) + return + } + + if form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplMustChangePassword, &form) + return + } + + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(form.Password), + MustChangePassword: optional.Some(false), + } + if err := user_service.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form) + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form) + case errors.Is(err, password.ErrIsPwned): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplMustChangePassword, &form) + case password.IsErrIsPwnedRequest(err): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplMustChangePassword, &form) + default: + ctx.ServerError("UpdateAuth", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + + log.Trace("User updated password: %s", ctx.Doer.Name) + + redirectTo := ctx.GetSiteCookie("redirect_to") + if redirectTo != "" { + middleware.DeleteRedirectToCookie(ctx.Resp) + } + ctx.RedirectToFirst(redirectTo) +} diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go new file mode 100644 index 0000000..5c93c14 --- /dev/null +++ b/routers/web/auth/webauthn.go @@ -0,0 +1,177 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + wa "code.gitea.io/gitea/modules/auth/webauthn" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +var tplWebAuthn base.TplName = "user/auth/webauthn" + +// WebAuthn shows the WebAuthn login page +func WebAuthn(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + + if CheckAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session")) + return + } + + hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, ctx.Session.Get("twofaUid").(int64)) + if err != nil { + ctx.ServerError("HasTwoFactorByUID", err) + return + } + + ctx.Data["HasTwoFactor"] = hasTwoFactor + + ctx.HTML(http.StatusOK, tplWebAuthn) +} + +// WebAuthnLoginAssertion submits a WebAuthn challenge to the browser +func WebAuthnLoginAssertion(ctx *context.Context) { + // Ensure user is in a WebAuthn session. + idSess, ok := ctx.Session.Get("twofaUid").(int64) + if !ok || idSess == 0 { + ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session")) + return + } + + user, err := user_model.GetUserByID(ctx, idSess) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + exists, err := auth.ExistsWebAuthnCredentialsForUID(ctx, user.ID) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if !exists { + ctx.ServerError("UserSignIn", errors.New("no device registered")) + return + } + + assertion, sessionData, err := wa.WebAuthn.BeginLogin((*wa.User)(user)) + if err != nil { + ctx.ServerError("webauthn.BeginLogin", err) + return + } + + if err := ctx.Session.Set("webauthnAssertion", sessionData); err != nil { + ctx.ServerError("Session.Set", err) + return + } + ctx.JSON(http.StatusOK, assertion) +} + +// WebAuthnLoginAssertionPost validates the signature and logs the user in +func WebAuthnLoginAssertionPost(ctx *context.Context) { + idSess, ok := ctx.Session.Get("twofaUid").(int64) + sessionData, okData := ctx.Session.Get("webauthnAssertion").(*webauthn.SessionData) + if !ok || !okData || sessionData == nil || idSess == 0 { + ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session")) + return + } + defer func() { + _ = ctx.Session.Delete("webauthnAssertion") + }() + + // Load the user from the db + user, err := user_model.GetUserByID(ctx, idSess) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + log.Trace("Finishing webauthn authentication with user: %s", user.Name) + + // Now we do the equivalent of webauthn.FinishLogin using a combination of our session data + // (from webauthnAssertion) and verify the provided request.0 + parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req) + if err != nil { + // Failed authentication attempt. + log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) + ctx.Status(http.StatusForbidden) + return + } + + dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, parsedResponse.RawID) + if err != nil { + ctx.ServerError("GetWebAuthnCredentialByCredID", err) + return + } + + // If the credential is legacy, assume the values are correct. The + // specification mandates these flags don't change. + if dbCred.Legacy { + dbCred.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() + dbCred.BackupState = parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() + dbCred.Legacy = false + + if err := dbCred.UpdateFromLegacy(ctx); err != nil { + ctx.ServerError("UpdateFromLegacy", err) + return + } + } + + // Validate the parsed response. + cred, err := wa.WebAuthn.ValidateLogin((*wa.User)(user), *sessionData, parsedResponse) + if err != nil { + // Failed authentication attempt. + log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) + ctx.Status(http.StatusForbidden) + return + } + + // Ensure that the credential wasn't cloned by checking if CloneWarning is set. + // (This is set if the sign counter is less than the one we have stored.) + if cred.Authenticator.CloneWarning { + log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr()) + ctx.Status(http.StatusForbidden) + return + } + + dbCred.SignCount = cred.Authenticator.SignCount + if err := dbCred.UpdateSignCount(ctx); err != nil { + ctx.ServerError("UpdateSignCount", err) + return + } + + // Now handle account linking if that's requested + if ctx.Session.Get("linkAccount") != nil { + if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { + ctx.ServerError("LinkAccountFromStore", err) + return + } + } + + remember := ctx.Session.Get("twofaRemember").(bool) + redirect := handleSignInFull(ctx, user, remember, false) + if redirect == "" { + redirect = setting.AppSubURL + "/" + } + _ = ctx.Session.Delete("twofaUid") + + ctx.JSONRedirect(redirect) +} diff --git a/routers/web/base.go b/routers/web/base.go new file mode 100644 index 0000000..78dde57 --- /dev/null +++ b/routers/web/base.go @@ -0,0 +1,98 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "errors" + "fmt" + "net/http" + "os" + "path" + "strings" + + "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/util" + "code.gitea.io/gitea/modules/web/routing" +) + +func storageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc { + prefix = strings.Trim(prefix, "/") + funcInfo := routing.GetFuncInfo(storageHandler, prefix) + + if storageSetting.MinioConfig.ServeDirect { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != "GET" && req.Method != "HEAD" { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + routing.UpdateFuncInfo(req.Context(), funcInfo) + + rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/") + rPath = util.PathJoinRelX(rPath) + + 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, http.StatusText(http.StatusNotFound), http.StatusNotFound) + 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), http.StatusInternalServerError) + return + } + + http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect) + }) + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != "GET" && req.Method != "HEAD" { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + routing.UpdateFuncInfo(req.Context(), funcInfo) + + rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/") + rPath = util.PathJoinRelX(rPath) + if rPath == "" || rPath == "." { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + fi, err := objStore.Stat(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, http.StatusText(http.StatusNotFound), http.StatusNotFound) + 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), http.StatusInternalServerError) + return + } + + fr, err := objStore.Open(rPath) + if err != nil { + log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) + return + } + defer fr.Close() + httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr) + }) +} diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go new file mode 100644 index 0000000..dd20663 --- /dev/null +++ b/routers/web/devtest/devtest.go @@ -0,0 +1,66 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package devtest + +import ( + "net/http" + "path" + "strings" + "time" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +// List all devtest templates, they will be used for e2e tests for the UI components +func List(ctx *context.Context) { + templateNames, err := templates.AssetFS().ListFiles("devtest", true) + if err != nil { + ctx.ServerError("AssetFS().ListFiles", err) + return + } + var subNames []string + for _, tmplName := range templateNames { + subName := strings.TrimSuffix(tmplName, ".tmpl") + if subName != "list" { + subNames = append(subNames, subName) + } + } + ctx.Data["SubNames"] = subNames + ctx.HTML(http.StatusOK, "devtest/list") +} + +func FetchActionTest(ctx *context.Context) { + _ = ctx.Req.ParseForm() + ctx.Flash.Info("fetch-action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "<br>" + + "Form: " + ctx.Req.Form.Encode() + "<br>" + + "PostForm: " + ctx.Req.PostForm.Encode(), + ) + time.Sleep(2 * time.Second) + ctx.JSONRedirect("") +} + +func Tmpl(ctx *context.Context) { + now := time.Now() + ctx.Data["TimeNow"] = now + ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) + ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second) + ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute) + ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute) + ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second) + ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second) + + if ctx.Req.Method == "POST" { + _ = ctx.Req.ParseForm() + ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"<br>"+ + "Form: "+ctx.Req.Form.Encode()+"<br>"+ + "PostForm: "+ctx.Req.PostForm.Encode(), + true, + ) + time.Sleep(2 * time.Second) + } + + ctx.HTML(http.StatusOK, base.TplName("devtest"+path.Clean("/"+ctx.Params("sub")))) +} diff --git a/routers/web/events/events.go b/routers/web/events/events.go new file mode 100644 index 0000000..52f20e0 --- /dev/null +++ b/routers/web/events/events.go @@ -0,0 +1,122 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package events + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/routers/web/auth" + "code.gitea.io/gitea/services/context" +) + +// Events listens for events +func Events(ctx *context.Context) { + // FIXME: Need to check if resp is actually a http.Flusher! - how though? + + // Set the headers related to event streaming. + ctx.Resp.Header().Set("Content-Type", "text/event-stream") + ctx.Resp.Header().Set("Cache-Control", "no-cache") + ctx.Resp.Header().Set("Connection", "keep-alive") + ctx.Resp.Header().Set("X-Accel-Buffering", "no") + ctx.Resp.WriteHeader(http.StatusOK) + + if !ctx.IsSigned { + // Return unauthorized status event + event := &eventsource.Event{ + Name: "close", + Data: "unauthorized", + } + _, _ = event.WriteTo(ctx) + ctx.Resp.Flush() + return + } + + // Listen to connection close and un-register messageChan + notify := ctx.Done() + ctx.Resp.Flush() + + shutdownCtx := graceful.GetManager().ShutdownContext() + + uid := ctx.Doer.ID + + messageChan := eventsource.GetManager().Register(uid) + + unregister := func() { + eventsource.GetManager().Unregister(uid, messageChan) + // ensure the messageChan is closed + for { + _, ok := <-messageChan + if !ok { + break + } + } + } + + if _, err := ctx.Resp.Write([]byte("\n")); err != nil { + log.Error("Unable to write to EventStream: %v", err) + unregister() + return + } + + timer := time.NewTicker(30 * time.Second) + +loop: + for { + select { + case <-timer.C: + event := &eventsource.Event{ + Name: "ping", + } + _, err := event.WriteTo(ctx.Resp) + if err != nil { + log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err) + go unregister() + break loop + } + ctx.Resp.Flush() + case <-notify: + go unregister() + break loop + case <-shutdownCtx.Done(): + go unregister() + break loop + case event, ok := <-messageChan: + if !ok { + break loop + } + + // Handle logout + if event.Name == "logout" { + if ctx.Session.ID() == event.Data { + _, _ = (&eventsource.Event{ + Name: "logout", + Data: "here", + }).WriteTo(ctx.Resp) + ctx.Resp.Flush() + go unregister() + auth.HandleSignOut(ctx) + break loop + } + // Replace the event - we don't want to expose the session ID to the user + event = &eventsource.Event{ + Name: "logout", + Data: "elsewhere", + } + } + + _, err := event.WriteTo(ctx.Resp) + if err != nil { + log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err) + go unregister() + break loop + } + ctx.Resp.Flush() + } + } + timer.Stop() +} diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go new file mode 100644 index 0000000..f61b832 --- /dev/null +++ b/routers/web/explore/code.go @@ -0,0 +1,144 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +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") + 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 := ctx.FormTrim("l") + keyword := ctx.FormTrim("q") + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["PageIsViewCode"] = true + + if keyword == "" { + ctx.HTML(http.StatusOK, tplExploreCode) + return + } + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + var ( + repoIDs []int64 + err error + isAdmin bool + ) + if ctx.Doer != nil { + isAdmin = ctx.Doer.IsAdmin + } + + // guest user or non-admin user + if ctx.Doer == nil || !isAdmin { + repoIDs, err = repo_model.FindUserCodeAccessibleRepoIDs(ctx, ctx.Doer) + if err != nil { + ctx.ServerError("FindUserCodeAccessibleRepoIDs", err) + return + } + } + + var ( + total int + searchResults []*code_indexer.Result + searchResultLanguages []*code_indexer.SearchResultLanguages + ) + + if (len(repoIDs) > 0) || isAdmin { + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: repoIDs, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["CodeIndexerUnavailable"] = true + } else { + ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + } + + loadRepoIDs := make([]int64, 0, len(searchResults)) + for _, result := range searchResults { + var find bool + for _, id := range loadRepoIDs { + if id == result.RepoID { + find = true + break + } + } + if !find { + loadRepoIDs = append(loadRepoIDs, result.RepoID) + } + } + + repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + + ctx.Data["RepoMaps"] = repoMaps + + if len(loadRepoIDs) != len(repoMaps) { + // Remove deleted repos from search results + cleanedSearchResults := make([]*code_indexer.Result, 0, len(repoMaps)) + for _, sr := range searchResults { + if _, found := repoMaps[sr.RepoID]; found { + cleanedSearchResults = append(cleanedSearchResults, sr) + } + } + + searchResults = cleanedSearchResults + } + } + + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + + pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) + pager.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 0000000..f8fd6ec --- /dev/null +++ b/routers/web/explore/org.go @@ -0,0 +1,48 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +// 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.Doer != nil { + visibleTypes = append(visibleTypes, structs.VisibleTypeLimited, structs.VisibleTypePrivate) + } + + supportedSortOrders := container.SetOf( + "newest", + "oldest", + "alphabetically", + "reversealphabetically", + ) + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = "newest" + ctx.SetFormString("sort", sortOrder) + } + + RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeOrganization, + ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, + Visible: visibleTypes, + + SupportedSortOrders: supportedSortOrders, + }, tplExploreUsers) +} diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go new file mode 100644 index 0000000..116b983 --- /dev/null +++ b/routers/web/explore/repo.go @@ -0,0 +1,193 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/services/context" +) + +const ( + // tplExploreRepos explore repositories page template + tplExploreRepos base.TplName = "explore/repos" + relevantReposOnlyParam string = "only_show_relevant" +) + +// RepoSearchOptions when calling search repositories +type RepoSearchOptions struct { + OwnerID int64 + Private bool + Restricted bool + PageSize int + OnlyShowRelevant bool + TplName base.TplName +} + +// RenderRepoSearch render repositories search page +// This function is also used to render the Admin Repository Management page. +func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { + // Sitemap index for sitemap paths + page := int(ctx.ParamsInt64("idx")) + isSitemap := ctx.Params("idx") != "" + if page <= 1 { + page = ctx.FormInt("page") + } + + if page <= 0 { + page = 1 + } + + if isSitemap { + opts.PageSize = setting.UI.SitemapPagingNum + } + + var ( + repos []*repo_model.Repository + count int64 + err error + orderBy db.SearchOrderBy + ) + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + + if order, ok := repo_model.OrderByFlatMap[sortOrder]; ok { + orderBy = order + } else { + sortOrder = "recentupdate" + orderBy = db.SearchOrderByRecentUpdated + } + ctx.Data["SortType"] = sortOrder + + keyword := ctx.FormTrim("q") + + ctx.Data["OnlyShowRelevant"] = opts.OnlyShowRelevant + + topicOnly := ctx.FormBool("topic") + ctx.Data["TopicOnly"] = topicOnly + + language := ctx.FormTrim("language") + ctx.Data["Language"] = language + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: opts.PageSize, + }, + Actor: ctx.Doer, + OrderBy: orderBy, + Private: opts.Private, + Keyword: keyword, + OwnerID: opts.OwnerID, + AllPublic: true, + AllLimited: true, + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + OnlyShowRelevant: opts.OnlyShowRelevant, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + if isSitemap { + m := sitemap.NewSitemap() + for _, item := range repos { + m.Add(sitemap.URL{URL: item.HTMLURL(), LastMod: item.UpdatedUnix.AsTimePtr()}) + } + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", err) + } + return + } + + ctx.Data["Keyword"] = keyword + ctx.Data["Total"] = count + ctx.Data["Repos"] = repos + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + pager := context.NewPagination(int(count), opts.PageSize, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "topic", "TopicOnly") + pager.AddParam(ctx, "language", "Language") + pager.AddParamString(relevantReposOnlyParam, fmt.Sprint(opts.OnlyShowRelevant)) + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + 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.Doer != nil && !ctx.Doer.IsAdmin { + ownerID = ctx.Doer.ID + } + + onlyShowRelevant := setting.UI.OnlyShowRelevantRepos + + _ = ctx.Req.ParseForm() // parse the form first, to prepare the ctx.Req.Form field + if len(ctx.Req.Form[relevantReposOnlyParam]) != 0 { + onlyShowRelevant = ctx.FormBool(relevantReposOnlyParam) + } + + RenderRepoSearch(ctx, &RepoSearchOptions{ + PageSize: setting.UI.ExplorePagingNum, + OwnerID: ownerID, + Private: ctx.Doer != nil, + TplName: tplExploreRepos, + OnlyShowRelevant: onlyShowRelevant, + }) +} diff --git a/routers/web/explore/topic.go b/routers/web/explore/topic.go new file mode 100644 index 0000000..95fecfe --- /dev/null +++ b/routers/web/explore/topic.go @@ -0,0 +1,41 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// TopicSearch search for creating topic +func TopicSearch(ctx *context.Context) { + opts := &repo_model.FindTopicOptions{ + Keyword: ctx.FormString("q"), + ListOptions: db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + } + + topics, total, err := repo_model.FindTopics(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError) + return + } + + topicResponses := make([]*api.TopicResponse, len(topics)) + for i, topic := range topics { + topicResponses[i] = convert.ToTopicResponse(topic) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, map[string]any{ + "topics": topicResponses, + }) +} diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go new file mode 100644 index 0000000..b79a79f --- /dev/null +++ b/routers/web/explore/user.go @@ -0,0 +1,163 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "bytes" + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +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 *user_model.SearchUserOptions, tplName base.TplName) { + // Sitemap index for sitemap paths + opts.Page = int(ctx.ParamsInt64("idx")) + isSitemap := ctx.Params("idx") != "" + if opts.Page <= 1 { + opts.Page = ctx.FormInt("page") + } + if opts.Page <= 1 { + opts.Page = 1 + } + + if isSitemap { + opts.PageSize = setting.UI.SitemapPagingNum + } + + var ( + users []*user_model.User + count int64 + err error + orderBy db.SearchOrderBy + ) + + // we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + ctx.Data["SortType"] = sortOrder + + switch sortOrder { + case "newest": + orderBy = "`user`.id DESC" + case "oldest": + orderBy = "`user`.id ASC" + case "leastupdate": + orderBy = "`user`.updated_unix ASC" + case "reversealphabetically": + orderBy = "`user`.name DESC" + case "lastlogin": + orderBy = "`user`.last_login_unix ASC" + case "reverselastlogin": + orderBy = "`user`.last_login_unix DESC" + case "alphabetically": + orderBy = "`user`.name ASC" + case "recentupdate": + fallthrough + default: + // in case the sortType is not valid, we set it to recentupdate + sortOrder = "recentupdate" + ctx.Data["SortType"] = "recentupdate" + orderBy = "`user`.updated_unix DESC" + } + + if opts.SupportedSortOrders != nil && !opts.SupportedSortOrders.Contains(sortOrder) { + ctx.NotFound("unsupported sort order", nil) + return + } + + opts.Keyword = ctx.FormTrim("q") + opts.OrderBy = orderBy + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + users, count, err = user_model.SearchUsers(ctx, opts) + if err != nil { + ctx.ServerError("SearchUsers", err) + return + } + } + if isSitemap { + m := sitemap.NewSitemap() + for _, item := range users { + m.Add(sitemap.URL{URL: item.HTMLURL(), LastMod: item.UpdatedUnix.AsTimePtr()}) + } + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", err) + } + return + } + + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Users"] = users + ctx.Data["UsersTwoFaStatus"] = user_model.UserList(users).GetTwoFaStatus(ctx) + ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + for paramKey, paramVal := range opts.ExtraParamStrings { + pager.AddParamString(paramKey, paramVal) + } + 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 + + supportedSortOrders := container.SetOf( + "newest", + "oldest", + "alphabetically", + "reversealphabetically", + ) + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = "newest" + ctx.SetFormString("sort", sortOrder) + } + + RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeIndividual, + ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, + IsActive: optional.Some(true), + Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + + SupportedSortOrders: supportedSortOrders, + }, tplExploreUsers) +} diff --git a/routers/web/feed/branch.go b/routers/web/feed/branch.go new file mode 100644 index 0000000..80ce2ad --- /dev/null +++ b/routers/web/feed/branch.go @@ -0,0 +1,50 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed +func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) { + commits, err := ctx.Repo.Commit.CommitsByRange(0, 10, "") + if err != nil { + ctx.ServerError("ShowBranchFeed", err) + return + } + + title := fmt.Sprintf("Latest commits for branch %s", ctx.Repo.BranchName) + link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.BranchNameSubURL()} + + feed := &feeds.Feed{ + Title: title, + Link: link, + Description: repo.Description, + Created: time.Now(), + } + + for _, commit := range commits { + feed.Items = append(feed.Items, &feeds.Item{ + Id: commit.ID.String(), + Title: strings.TrimSpace(strings.Split(commit.Message(), "\n")[0]), + Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()}, + Author: &feeds.Author{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + Description: commit.Message(), + Content: commit.Message(), + }) + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go new file mode 100644 index 0000000..d3d01a8 --- /dev/null +++ b/routers/web/feed/convert.go @@ -0,0 +1,344 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "fmt" + "html" + "html/template" + "net/http" + "net/url" + "strconv" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "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/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" + "github.com/jaytaylor/html2text" +) + +func toBranchLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/branch/" + util.PathEscapeSegments(act.GetBranch()) +} + +func toTagLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/tag/" + util.PathEscapeSegments(act.GetTag()) +} + +func toIssueLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/issues/" + url.PathEscape(act.GetIssueInfos()[0]) +} + +func toPullLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/pulls/" + url.PathEscape(act.GetIssueInfos()[0]) +} + +func toSrcLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/" + util.PathEscapeSegments(act.GetBranch()) +} + +func toReleaseLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/releases/tag/" + util.PathEscapeSegments(act.GetBranch()) +} + +// renderMarkdown creates a minimal markdown render context from an action. +// If rendering fails, the original markdown text is returned +func renderMarkdown(ctx *context.Context, act *activities_model.Action, content string) template.HTML { + markdownCtx := &markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + Base: act.GetRepoLink(ctx), + }, + Type: markdown.MarkupName, + Metas: map[string]string{ + "user": act.GetRepoUserName(ctx), + "repo": act.GetRepoName(ctx), + }, + } + markdown, err := markdown.RenderString(markdownCtx, content) + if err != nil { + return templates.SanitizeHTML(content) // old code did so: use SanitizeHTML to render in tmpl + } + return markdown +} + +// feedActionsToFeedItems convert gitea's Action feed to feeds Item +func feedActionsToFeedItems(ctx *context.Context, actions activities_model.ActionList) (items []*feeds.Item, err error) { + for _, act := range actions { + act.LoadActUser(ctx) + + // TODO: the code seems quite strange (maybe not right) + // sometimes it uses text content but sometimes it uses HTML content + // it should clearly defines which kind of content it should use for the feed items: plan text or rich HTML + var title, desc string + var content template.HTML + + link := &feeds.Link{Href: act.GetCommentHTMLURL(ctx)} + + // title + title = act.ActUser.GetDisplayName() + " " + var titleExtra template.HTML + switch act.OpType { + case activities_model.ActionCreateRepo: + titleExtra = ctx.Locale.Tr("action.create_repo", act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + link.Href = act.GetRepoAbsoluteLink(ctx) + case activities_model.ActionRenameRepo: + titleExtra = ctx.Locale.Tr("action.rename_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + link.Href = act.GetRepoAbsoluteLink(ctx) + case activities_model.ActionCommitRepo: + link.Href = toBranchLink(ctx, act) + if len(act.Content) != 0 { + titleExtra = ctx.Locale.Tr("action.commit_repo", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx)) + } else { + titleExtra = ctx.Locale.Tr("action.create_branch", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx)) + } + case activities_model.ActionCreateIssue: + link.Href = toIssueLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.create_issue", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionCreatePullRequest: + link.Href = toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.create_pull_request", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionTransferRepo: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.transfer_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + case activities_model.ActionPushTag: + link.Href = toTagLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.push_tag", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetTag(), act.ShortRepoPath(ctx)) + case activities_model.ActionCommentIssue: + issueLink := toIssueLink(ctx, act) + if link.Href == "#" { + link.Href = issueLink + } + titleExtra = ctx.Locale.Tr("action.comment_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionMergePullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionAutoMergePullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.auto_merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionCloseIssue: + issueLink := toIssueLink(ctx, act) + if link.Href == "#" { + link.Href = issueLink + } + titleExtra = ctx.Locale.Tr("action.close_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionReopenIssue: + issueLink := toIssueLink(ctx, act) + if link.Href == "#" { + link.Href = issueLink + } + titleExtra = ctx.Locale.Tr("action.reopen_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionClosePullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.close_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionReopenPullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.reopen_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionDeleteTag: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.delete_tag", act.GetRepoAbsoluteLink(ctx), act.GetTag(), act.ShortRepoPath(ctx)) + case activities_model.ActionDeleteBranch: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.delete_branch", act.GetRepoAbsoluteLink(ctx), html.EscapeString(act.GetBranch()), act.ShortRepoPath(ctx)) + case activities_model.ActionMirrorSyncPush: + srcLink := toSrcLink(ctx, act) + if link.Href == "#" { + link.Href = srcLink + } + titleExtra = ctx.Locale.Tr("action.mirror_sync_push", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx)) + case activities_model.ActionMirrorSyncCreate: + srcLink := toSrcLink(ctx, act) + if link.Href == "#" { + link.Href = srcLink + } + titleExtra = ctx.Locale.Tr("action.mirror_sync_create", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx)) + case activities_model.ActionMirrorSyncDelete: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.mirror_sync_delete", act.GetRepoAbsoluteLink(ctx), act.GetBranch(), act.ShortRepoPath(ctx)) + case activities_model.ActionApprovePullRequest: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.approve_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionRejectPullRequest: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.reject_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionCommentPull: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.comment_pull", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionPublishRelease: + releaseLink := toReleaseLink(ctx, act) + if link.Href == "#" { + link.Href = releaseLink + } + titleExtra = ctx.Locale.Tr("action.publish_release", act.GetRepoAbsoluteLink(ctx), releaseLink, act.ShortRepoPath(ctx), act.Content) + case activities_model.ActionPullReviewDismissed: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.review_dismissed", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx), act.GetIssueInfos()[1]) + case activities_model.ActionStarRepo: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.starred_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx)) + case activities_model.ActionWatchRepo: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.watched_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx)) + default: + return nil, fmt.Errorf("unknown action type: %v", act.OpType) + } + + // description & content + { + switch act.OpType { + case activities_model.ActionCommitRepo, activities_model.ActionMirrorSyncPush: + push := templates.ActionContent2Commits(act) + + for _, commit := range push.Commits { + if len(desc) != 0 { + desc += "\n\n" + } + desc += fmt.Sprintf("<a href=\"%s\">%s</a>\n%s", + html.EscapeString(fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), commit.Sha1)), + commit.Sha1, + templates.RenderCommitMessage(ctx, commit.Message, nil), + ) + } + + if push.Len > 1 { + link = &feeds.Link{Href: fmt.Sprintf("%s/%s", setting.AppSubURL, push.CompareURL)} + } else if push.Len == 1 { + link = &feeds.Link{Href: fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), push.Commits[0].Sha1)} + } + + case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest: + desc = strings.Join(act.GetIssueInfos(), "#") + content = renderMarkdown(ctx, act, act.GetIssueContent(ctx)) + case activities_model.ActionCommentIssue, activities_model.ActionApprovePullRequest, activities_model.ActionRejectPullRequest, activities_model.ActionCommentPull: + desc = act.GetIssueTitle(ctx) + comment := act.GetIssueInfos()[1] + if strings.HasSuffix(comment, "…") { + // Comment was truncated get the full content from the database. + // This truncation is done in `NotifyCreateIssueComment`. + commentModel, err := issues_model.GetCommentByID(ctx, act.CommentID) + if err != nil { + log.Error("Couldn't get comment[%d] for RSS feed: %v", act.CommentID, err) + } else { + comment = commentModel.Content + } + } + if len(comment) != 0 { + desc += "\n\n" + string(renderMarkdown(ctx, act, comment)) + } + case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: + desc = act.GetIssueInfos()[1] + case activities_model.ActionCloseIssue, activities_model.ActionReopenIssue, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest: + desc = act.GetIssueTitle(ctx) + case activities_model.ActionPullReviewDismissed: + desc = ctx.Locale.TrString("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2] + } + } + if len(content) == 0 { + content = templates.SanitizeHTML(desc) + } + + // It's a common practice for feed generators to use plain text titles. + // See https://codeberg.org/forgejo/forgejo/pulls/1595 + plainTitle, err := html2text.FromString(title+" "+string(titleExtra), html2text.Options{OmitLinks: true}) + if err != nil { + return nil, err + } + + items = append(items, &feeds.Item{ + Title: plainTitle, + Link: link, + Description: desc, + IsPermaLink: "false", + Author: &feeds.Author{ + Name: act.ActUser.GetDisplayName(), + Email: act.ActUser.GetEmail(), + }, + Id: fmt.Sprintf("%v: %v", strconv.FormatInt(act.ID, 10), link.Href), + Created: act.CreatedUnix.AsTime(), + Content: string(content), + }) + } + return items, err +} + +// GetFeedType return if it is a feed request and altered name and feed type. +func GetFeedType(name string, req *http.Request) (bool, string, string) { + if strings.HasSuffix(name, ".rss") || + strings.Contains(req.Header.Get("Accept"), "application/rss+xml") { + return true, strings.TrimSuffix(name, ".rss"), "rss" + } + + if strings.HasSuffix(name, ".atom") || + strings.Contains(req.Header.Get("Accept"), "application/atom+xml") { + return true, strings.TrimSuffix(name, ".atom"), "atom" + } + + return false, name, "" +} + +// feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item +func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) { + for _, rel := range releases { + err := rel.LoadAttributes(ctx) + if err != nil { + return nil, err + } + + var title string + var content template.HTML + + if rel.IsTag { + title = rel.TagName + } else { + title = rel.Title + } + + link := &feeds.Link{Href: rel.HTMLURL()} + content, err = markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + Base: rel.Repo.Link(), + }, + Metas: rel.Repo.ComposeMetas(ctx), + }, rel.Note) + if err != nil { + return nil, err + } + + items = append(items, &feeds.Item{ + Title: title, + Link: link, + Created: rel.CreatedUnix.AsTime(), + Author: &feeds.Author{ + Name: rel.Publisher.GetDisplayName(), + Email: rel.Publisher.GetEmail(), + }, + Id: fmt.Sprintf("%v: %v", strconv.FormatInt(rel.ID, 10), link.Href), + Content: string(content), + }) + } + + return items, err +} diff --git a/routers/web/feed/file.go b/routers/web/feed/file.go new file mode 100644 index 0000000..1ab768f --- /dev/null +++ b/routers/web/feed/file.go @@ -0,0 +1,62 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed +func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) { + fileName := ctx.Repo.TreePath + if len(fileName) == 0 { + return + } + commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange( + git.CommitsByFileAndRangeOptions{ + Revision: ctx.Repo.RefName, + File: fileName, + Page: 1, + }) + if err != nil { + ctx.ServerError("ShowBranchFeed", err) + return + } + + title := fmt.Sprintf("Latest commits for file %s", ctx.Repo.TreePath) + + link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)} + + feed := &feeds.Feed{ + Title: title, + Link: link, + Description: repo.Description, + Created: time.Now(), + } + + for _, commit := range commits { + feed.Items = append(feed.Items, &feeds.Item{ + Id: commit.ID.String(), + Title: strings.TrimSpace(strings.Split(commit.Message(), "\n")[0]), + Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()}, + Author: &feeds.Author{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + Description: commit.Message(), + Content: commit.Message(), + }) + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go new file mode 100644 index 0000000..08cbcd9 --- /dev/null +++ b/routers/web/feed/profile.go @@ -0,0 +1,87 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowUserFeedRSS show user activity as RSS feed +func ShowUserFeedRSS(ctx *context.Context) { + showUserFeed(ctx, "rss") +} + +// ShowUserFeedAtom show user activity as Atom feed +func ShowUserFeedAtom(ctx *context.Context) { + showUserFeed(ctx, "atom") +} + +// showUserFeed show user activity as RSS / Atom feed +func showUserFeed(ctx *context.Context, formatType string) { + includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + + actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: includePrivate, + OnlyPerformedBy: !ctx.ContextUser.IsOrganization(), + IncludeDeleted: false, + Date: ctx.FormString("date"), + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + ctxUserDescription, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + Base: ctx.ContextUser.HTMLURL(), + }, + Metas: map[string]string{ + "user": ctx.ContextUser.GetDisplayName(), + }, + }, ctx.ContextUser.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + feed := &feeds.Feed{ + Title: ctx.Locale.TrString("home.feed_of", ctx.ContextUser.DisplayName()), + Link: &feeds.Link{Href: ctx.ContextUser.HTMLURL()}, + Description: string(ctxUserDescription), + Created: time.Now(), + } + + feed.Items, err = feedActionsToFeedItems(ctx, actions) + if err != nil { + ctx.ServerError("convert feed", err) + return + } + + writeFeed(ctx, feed, formatType) +} + +// writeFeed write a feeds.Feed as atom or rss to ctx.Resp +func writeFeed(ctx *context.Context, feed *feeds.Feed, formatType string) { + if formatType == "atom" { + ctx.Resp.Header().Set("Content-Type", "application/atom+xml;charset=utf-8") + if err := feed.WriteAtom(ctx.Resp); err != nil { + ctx.ServerError("Render Atom failed", err) + } + } else { + ctx.Resp.Header().Set("Content-Type", "application/rss+xml;charset=utf-8") + if err := feed.WriteRss(ctx.Resp); err != nil { + ctx.ServerError("Render RSS failed", err) + } + } +} diff --git a/routers/web/feed/release.go b/routers/web/feed/release.go new file mode 100644 index 0000000..fb6e3ad --- /dev/null +++ b/routers/web/feed/release.go @@ -0,0 +1,52 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "time" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// shows tags and/or releases on the repo as RSS / Atom feed +func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) { + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeTags: !isReleasesOnly, + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("GetReleasesByRepoID", err) + return + } + + var title string + var link *feeds.Link + + if isReleasesOnly { + title = ctx.Locale.TrString("repo.release.releases_for", repo.FullName()) + link = &feeds.Link{Href: repo.HTMLURL() + "/release"} + } else { + title = ctx.Locale.TrString("repo.release.tags_for", repo.FullName()) + link = &feeds.Link{Href: repo.HTMLURL() + "/tags"} + } + + feed := &feeds.Feed{ + Title: title, + Link: link, + Description: repo.Description, + Created: time.Now(), + } + + feed.Items, err = releasesToFeedItems(ctx, releases) + if err != nil { + ctx.ServerError("releasesToFeedItems", err) + return + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/feed/render.go b/routers/web/feed/render.go new file mode 100644 index 0000000..dc99fb4 --- /dev/null +++ b/routers/web/feed/render.go @@ -0,0 +1,19 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "code.gitea.io/gitea/services/context" +) + +// RenderBranchFeed render format for branch or file +func RenderBranchFeed(feedType string) func(ctx *context.Context) { + return func(ctx *context.Context) { + if ctx.Repo.TreePath == "" { + ShowBranchFeed(ctx, ctx.Repo.Repository, feedType) + } else { + ShowFileFeed(ctx, ctx.Repo.Repository, feedType) + } + } +} diff --git a/routers/web/feed/repo.go b/routers/web/feed/repo.go new file mode 100644 index 0000000..a0033c7 --- /dev/null +++ b/routers/web/feed/repo.go @@ -0,0 +1,44 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "time" + + activities_model "code.gitea.io/gitea/models/activities" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowRepoFeed shows user activity on the repo as RSS / Atom feed +func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) { + actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + OnlyPerformedByActor: true, + RequestedRepo: repo, + Actor: ctx.Doer, + IncludePrivate: true, + Date: ctx.FormString("date"), + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + feed := &feeds.Feed{ + Title: ctx.Locale.TrString("home.feed_of", repo.FullName()), + Link: &feeds.Link{Href: repo.HTMLURL()}, + Description: repo.Description, + Created: time.Now(), + } + + feed.Items, err = feedActionsToFeedItems(ctx, actions) + if err != nil { + ctx.ServerError("convert feed", err) + return + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/githttp.go b/routers/web/githttp.go new file mode 100644 index 0000000..5f1dedc --- /dev/null +++ b/routers/web/githttp.go @@ -0,0 +1,42 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" +) + +func requireSignIn(ctx *context.Context) { + if !setting.Service.RequireSignInView { + return + } + + // 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="Gitea"`) + ctx.Error(http.StatusUnauthorized) + } +} + +func gitHTTPRouters(m *web.Route) { + m.Group("", func() { + m.Methods("POST,OPTIONS", "/git-upload-pack", repo.ServiceUploadPack) + m.Methods("POST,OPTIONS", "/git-receive-pack", repo.ServiceReceivePack) + m.Methods("GET,OPTIONS", "/info/refs", repo.GetInfoRefs) + m.Methods("GET,OPTIONS", "/HEAD", repo.GetTextFile("HEAD")) + m.Methods("GET,OPTIONS", "/objects/info/alternates", repo.GetTextFile("objects/info/alternates")) + m.Methods("GET,OPTIONS", "/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates")) + m.Methods("GET,OPTIONS", "/objects/info/packs", repo.GetInfoPacks) + m.Methods("GET,OPTIONS", "/objects/info/{file:[^/]*}", repo.GetTextFile("")) + m.Methods("GET,OPTIONS", "/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38,62}}", repo.GetLooseObject) + m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.pack", repo.GetPackFile) + m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.idx", repo.GetIdxFile) + }, ignSignInAndCsrf, requireSignIn, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context.UserAssignmentWeb()) +} diff --git a/routers/web/goget.go b/routers/web/goget.go new file mode 100644 index 0000000..8d5612e --- /dev/null +++ b/routers/web/goget.go @@ -0,0 +1,93 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "fmt" + "html" + "net/http" + "net/url" + "path" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +func goGet(ctx *context.Context) { + if ctx.Req.Method != "GET" || len(ctx.Req.URL.RawQuery) < 8 || ctx.FormString("go-get") != "1" { + return + } + + parts := strings.SplitN(ctx.Req.URL.EscapedPath(), "/", 4) + + if len(parts) < 3 { + return + } + + ownerName := parts[1] + repoName := parts[2] + + // Quick responses appropriate go-get meta with status 200 + // regardless of if user have access to the repository, + // or the repository does not exist at all. + // This is particular a workaround for "go get" command which does not respect + // .netrc file. + + trimmedRepoName := strings.TrimSuffix(repoName, ".git") + + if ownerName == "" || trimmedRepoName == "" { + _, _ = ctx.Write([]byte(`<!doctype html> +<html> + <body> + invalid import path + </body> +</html> +`)) + ctx.Status(http.StatusBadRequest) + return + } + branchName := setting.Repository.DefaultBranch + + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName) + if err == nil && len(repo.DefaultBranch) > 0 { + branchName = repo.DefaultBranch + } + prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName)) + + appURL, _ := url.Parse(setting.AppURL) + + insecure := "" + if appURL.Scheme == string(setting.HTTP) { + insecure = "--insecure " + } + + goGetImport := context.ComposeGoGetImport(ownerName, trimmedRepoName) + + var cloneURL string + if setting.Repository.GoGetCloneURLProtocol == "ssh" { + cloneURL = repo_model.ComposeSSHCloneURL(ownerName, repoName) + } else { + cloneURL = repo_model.ComposeHTTPSCloneURL(ownerName, repoName) + } + goImportContent := fmt.Sprintf("%s git %s", goGetImport, cloneURL /*CloneLink*/) + goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/) + goGetCli := fmt.Sprintf("go get %s%s", insecure, goGetImport) + + res := fmt.Sprintf(`<!doctype html> +<html> + <head> + <meta name="go-import" content="%s"> + <meta name="go-source" content="%s"> + </head> + <body> + %s + </body> +</html>`, html.EscapeString(goImportContent), html.EscapeString(goSourceContent), html.EscapeString(goGetCli)) + + ctx.RespHeader().Set("Content-Type", "text/html") + _, _ = ctx.Write([]byte(res)) +} diff --git a/routers/web/healthcheck/check.go b/routers/web/healthcheck/check.go new file mode 100644 index 0000000..83dfe62 --- /dev/null +++ b/routers/web/healthcheck/check.go @@ -0,0 +1,140 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package healthcheck + +import ( + "context" + "net/http" + "os" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +type status string + +const ( + // Pass healthy (acceptable aliases: "ok" to support Node's Terminus and "up" for Java's SpringBoot) + // fail unhealthy (acceptable aliases: "error" to support Node's Terminus and "down" for Java's SpringBoot), and + // warn healthy, with some concerns. + // + // ref https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check#section-3.1 + // status: (required) indicates whether the service status is acceptable + // or not. API publishers SHOULD use following values for the field: + // The value of the status field is case-insensitive and is tightly + // related with the HTTP Response code returned by the health endpoint. + // For "pass" status, HTTP Response code in the 2xx-3xx range MUST be + // used. For "fail" status, HTTP Response code in the 4xx-5xx range + // MUST be used. In case of the "warn" status, endpoints MUST return + // HTTP status in the 2xx-3xx range, and additional information SHOULD + // be provided, utilizing optional fields of the Response. + Pass status = "pass" + Fail status = "fail" + warn status = "warn" +) + +func (s status) ToHTTPStatus() int { + if s == Pass || s == warn { + return http.StatusOK + } + return http.StatusFailedDependency +} + +type checks map[string][]componentStatus + +// Response is the data returned by the health endpoint, which will be marshaled to JSON format +type Response struct { + Status status `json:"status"` + Description string `json:"description"` // a human-friendly description of the service + Checks checks `json:"checks,omitempty"` // The Checks Object, should be omitted on installation route +} + +// componentStatus presents one status of a single check object +// an object that provides detailed health statuses of additional downstream systems and endpoints +// which can affect the overall health of the main API. +type componentStatus struct { + Status status `json:"status"` + Time string `json:"time"` // the date-time, in ISO8601 format + Output string `json:"output,omitempty"` // this field SHOULD be omitted for "pass" state. +} + +// Check is the health check API handler +func Check(w http.ResponseWriter, r *http.Request) { + rsp := Response{ + Status: Pass, + Description: setting.AppName, + Checks: make(checks), + } + + statuses := make([]status, 0) + if setting.InstallLock { + statuses = append(statuses, checkDatabase(r.Context(), rsp.Checks)) + statuses = append(statuses, checkCache(rsp.Checks)) + } + for _, s := range statuses { + if s != Pass { + rsp.Status = Fail + break + } + } + + data, _ := json.MarshalIndent(rsp, "", " ") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(rsp.Status.ToHTTPStatus()) + _, _ = w.Write(data) +} + +// database checks gitea database status +func checkDatabase(ctx context.Context, checks checks) status { + st := componentStatus{} + if err := db.GetEngine(ctx).Ping(); err != nil { + st.Status = Fail + st.Time = getCheckTime() + log.Error("database ping failed with error: %v", err) + } else { + st.Status = Pass + st.Time = getCheckTime() + } + + if setting.Database.Type.IsSQLite3() && st.Status == Pass { + if !setting.EnableSQLite3 { + st.Status = Fail + st.Time = getCheckTime() + log.Error("SQLite3 health check failed with error: %v", "this Forgejo binary is built without SQLite3 enabled") + } else { + if _, err := os.Stat(setting.Database.Path); err != nil { + st.Status = Fail + st.Time = getCheckTime() + log.Error("SQLite3 file exists check failed with error: %v", err) + } + } + } + + checks["database:ping"] = []componentStatus{st} + return st.Status +} + +// cache checks gitea cache status +func checkCache(checks checks) status { + st := componentStatus{} + if err := cache.GetCache().Ping(); err != nil { + st.Status = Fail + st.Time = getCheckTime() + log.Error("cache ping failed with error: %v", err) + } else { + st.Status = Pass + st.Time = getCheckTime() + } + checks["cache:ping"] = []componentStatus{st} + return st.Status +} + +func getCheckTime() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/routers/web/home.go b/routers/web/home.go new file mode 100644 index 0000000..d4be093 --- /dev/null +++ b/routers/web/home.go @@ -0,0 +1,117 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/routers/web/auth" + "code.gitea.io/gitea/routers/web/user" + "code.gitea.io/gitea/services/context" +) + +const ( + // tplHome home page template + tplHome base.TplName = "home" +) + +// Home render home page +func Home(ctx *context.Context) { + if ctx.IsSigned { + if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, auth.TplActivate) + } else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if ctx.Doer.MustChangePassword { + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") + } else { + user.Dashboard(ctx) + } + return + // Check non-logged users landing page. + } else if setting.LandingPageURL != setting.LandingPageHome { + ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL)) + return + } + + // Check auto-login. + if ctx.GetSiteCookie(setting.CookieRememberName) != "" { + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + + ctx.Data["PageIsHome"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.HTML(http.StatusOK, tplHome) +} + +// HomeSitemap renders the main sitemap +func HomeSitemap(ctx *context.Context) { + m := sitemap.NewSitemapIndex() + if !setting.Service.Explore.DisableUsersPage { + _, cnt, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Type: user_model.UserTypeIndividual, + ListOptions: db.ListOptions{PageSize: 1}, + IsActive: optional.Some(true), + Visible: []structs.VisibleType{structs.VisibleTypePublic}, + }) + if err != nil { + ctx.ServerError("SearchUsers", err) + return + } + count := int(cnt) + idx := 1 + for i := 0; i < count; i += setting.UI.SitemapPagingNum { + m.Add(sitemap.URL{URL: setting.AppURL + "explore/users/sitemap-" + strconv.Itoa(idx) + ".xml"}) + idx++ + } + } + + _, cnt, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: 1, + }, + Actor: ctx.Doer, + AllPublic: true, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + count := int(cnt) + idx := 1 + for i := 0; i < count; i += setting.UI.SitemapPagingNum { + m.Add(sitemap.URL{URL: setting.AppURL + "explore/repos/sitemap-" + strconv.Itoa(idx) + ".xml"}) + idx++ + } + + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", err) + } +} + +// 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 0000000..46c13f0 --- /dev/null +++ b/routers/web/metrics.go @@ -0,0 +1,33 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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, "", http.StatusUnauthorized) + return + } + got := []byte(header) + want := []byte("Bearer " + setting.Metrics.Token) + if subtle.ConstantTimeCompare(got, want) != 1 { + http.Error(resp, "", http.StatusUnauthorized) + return + } + promhttp.Handler().ServeHTTP(resp, req) +} diff --git a/routers/web/misc/markup.go b/routers/web/misc/markup.go new file mode 100644 index 0000000..2dbbd6f --- /dev/null +++ b/routers/web/misc/markup.go @@ -0,0 +1,18 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" +) + +// Markup render markup document to HTML +func Markup(ctx *context.Context) { + form := web.GetForm(ctx).(*api.MarkupOption) + common.RenderMarkup(ctx.Base, ctx.Repo, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) +} diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go new file mode 100644 index 0000000..54c9376 --- /dev/null +++ b/routers/web/misc/misc.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + "path" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +func SSHInfo(rw http.ResponseWriter, req *http.Request) { + if !git.SupportProcReceive { + rw.WriteHeader(http.StatusNotFound) + return + } + rw.Header().Set("content-type", "text/json;charset=UTF-8") + _, err := rw.Write([]byte(`{"type":"gitea","version":1}`)) + if err != nil { + log.Error("fail to write result: err: %v", err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + rw.WriteHeader(http.StatusOK) +} + +func DummyOK(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) +} + +func RobotsTxt(w http.ResponseWriter, req *http.Request) { + robotsTxt := util.FilePathJoinAbs(setting.CustomPath, "public/robots.txt") + if ok, _ := util.IsExist(robotsTxt); !ok { + robotsTxt = util.FilePathJoinAbs(setting.CustomPath, "robots.txt") // the legacy "robots.txt" + } + httpcache.SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) + http.ServeFile(w, req, robotsTxt) +} + +func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request) { + return func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, path.Join(setting.StaticURLPrefix, target), http.StatusMovedPermanently) + } +} diff --git a/routers/web/misc/swagger-forgejo.go b/routers/web/misc/swagger-forgejo.go new file mode 100644 index 0000000..e3aff02 --- /dev/null +++ b/routers/web/misc/swagger-forgejo.go @@ -0,0 +1,19 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" +) + +// tplSwagger swagger page template +const tplForgejoSwagger base.TplName = "swagger/forgejo-ui" + +func SwaggerForgejo(ctx *context.Context) { + ctx.Data["APIVersion"] = "v1" + ctx.HTML(http.StatusOK, tplForgejoSwagger) +} diff --git a/routers/web/misc/swagger.go b/routers/web/misc/swagger.go new file mode 100644 index 0000000..5fddfa8 --- /dev/null +++ b/routers/web/misc/swagger.go @@ -0,0 +1,20 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" +) + +// tplSwagger swagger page template +const tplSwagger base.TplName = "swagger/ui" + +// Swagger render swagger-ui page with v1 json +func Swagger(ctx *context.Context) { + ctx.Data["APIJSONVersion"] = "v1" + ctx.HTML(http.StatusOK, tplSwagger) +} diff --git a/routers/web/nodeinfo.go b/routers/web/nodeinfo.go new file mode 100644 index 0000000..f1cc7bf --- /dev/null +++ b/routers/web/nodeinfo.go @@ -0,0 +1,32 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +type nodeInfoLinks struct { + Links []nodeInfoLink `json:"links"` +} + +type nodeInfoLink struct { + Href string `json:"href"` + Rel string `json:"rel"` +} + +// NodeInfoLinks returns links to the node info endpoint +func NodeInfoLinks(ctx *context.Context) { + nodeinfolinks := &nodeInfoLinks{ + Links: []nodeInfoLink{{ + fmt.Sprintf("%sapi/v1/nodeinfo", setting.AppURL), + "http://nodeinfo.diaspora.software/ns/schema/2.1", + }}, + } + ctx.JSON(http.StatusOK, nodeinfolinks) +} diff --git a/routers/web/org/home.go b/routers/web/org/home.go new file mode 100644 index 0000000..92793d9 --- /dev/null +++ b/routers/web/org/home.go @@ -0,0 +1,189 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "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/setting" + "code.gitea.io/gitea/modules/util" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplOrgHome base.TplName = "org/home" +) + +// Home show organization home page +func Home(ctx *context.Context) { + uname := ctx.Params(":username") + + if strings.HasSuffix(uname, ".keys") || strings.HasSuffix(uname, ".gpg") { + ctx.NotFound("", nil) + return + } + + ctx.SetParams(":org", uname) + context.HandleOrgAssignment(ctx) + if ctx.Written() { + return + } + + org := ctx.Org.Organization + + ctx.Data["PageIsUserProfile"] = true + ctx.Data["Title"] = org.DisplayName() + + var orderBy db.SearchOrderBy + sortOrder := ctx.FormString("sort") + if _, ok := repo_model.OrderByFlatMap[sortOrder]; !ok { + sortOrder = setting.UI.ExploreDefaultSort // TODO: add new default sort order for org home? + } + ctx.Data["SortType"] = sortOrder + orderBy = repo_model.OrderByFlatMap[sortOrder] + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + language := ctx.FormTrim("language") + ctx.Data["Language"] = language + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + var ( + repos []*repo_model.Repository + count int64 + err error + ) + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Keyword: keyword, + OwnerID: org.ID, + OrderBy: orderBy, + Private: ctx.IsSigned, + Actor: ctx.Doer, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + opts := &organization.FindOrgMembersOpts{ + OrgID: org.ID, + PublicOnly: ctx.Org.PublicMemberOnly, + ListOptions: db.ListOptions{Page: 1, PageSize: 25}, + } + members, _, err := organization.FindOrgMembers(ctx, opts) + if err != nil { + ctx.ServerError("FindOrgMembers", err) + return + } + + ctx.Data["Repos"] = repos + ctx.Data["Total"] = count + ctx.Data["Members"] = members + ctx.Data["Teams"] = ctx.Org.Teams + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + ctx.Data["PageIsViewRepositories"] = true + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(int(count), setting.UI.User.RepoPagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParamString("language", language) + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + ctx.Data["Page"] = pager + + ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 + + profileDbRepo, profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) + defer profileClose() + prepareOrgProfileReadme(ctx, profileGitRepo, profileDbRepo, profileReadmeBlob) + + ctx.HTML(http.StatusOK, tplOrgHome) +} + +func prepareOrgProfileReadme(ctx *context.Context, profileGitRepo *git.Repository, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) { + if profileGitRepo == nil || profileReadme == nil { + return + } + + if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("failed to GetBlobContent: %v", err) + } else { + if profileContent, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + GitRepo: profileGitRepo, + Links: markup.Links{ + // Pass repo link to markdown render for the full link of media elements. + // The profile of default branch would be shown. + Base: profileDbRepo.Link(), + BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + }, + Metas: map[string]string{"mode": "document"}, + }, bytes); err != nil { + log.Error("failed to RenderString: %v", err) + } else { + ctx.Data["ProfileReadme"] = profileContent + } + } +} diff --git a/routers/web/org/main_test.go b/routers/web/org/main_test.go new file mode 100644 index 0000000..92237d6 --- /dev/null +++ b/routers/web/org/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/org/members.go b/routers/web/org/members.go new file mode 100644 index 0000000..9a3d60e --- /dev/null +++ b/routers/web/org/members.go @@ -0,0 +1,144 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +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.FormInt("page") + if page <= 1 { + page = 1 + } + + opts := &organization.FindOrgMembersOpts{ + OrgID: org.ID, + PublicOnly: true, + } + + if ctx.Doer != nil { + isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember") + return + } + opts.PublicOnly = !isMember && !ctx.Doer.IsAdmin + } + ctx.Data["PublicOnly"] = opts.PublicOnly + + total, err := organization.CountOrgMembers(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CountOrgMembers") + return + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(int(total), setting.UI.MembersPagingNum, page, 5) + opts.ListOptions.Page = page + opts.ListOptions.PageSize = setting.UI.MembersPagingNum + members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts) + if err != nil { + ctx.ServerError("GetMembers", err) + return + } + ctx.Data["Page"] = pager + ctx.Data["Members"] = members + ctx.Data["MembersIsPublicMember"] = membersIsPublic + ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID) + ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx) + + ctx.HTML(http.StatusOK, tplMembers) +} + +// MembersAction response for operation to a member of organization +func MembersAction(ctx *context.Context) { + uid := ctx.FormInt64("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.Doer.ID != uid && !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = organization.ChangeOrgUserStatus(ctx, org.ID, uid, false) + case "public": + if ctx.Doer.ID != uid && !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = organization.ChangeOrgUserStatus(ctx, org.ID, uid, true) + case "remove": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = models.RemoveOrgUser(ctx, org.ID, uid) + if organization.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + ctx.JSONRedirect(ctx.Org.OrgLink + "/members") + return + } + case "leave": + err = models.RemoveOrgUser(ctx, org.ID, ctx.Doer.ID) + if err == nil { + ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName())) + ctx.JSON(http.StatusOK, map[string]any{ + "redirect": "", // keep the user stay on current page, in case they want to do other operations. + }) + } else if organization.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + ctx.JSONRedirect(ctx.Org.OrgLink + "/members") + } else { + log.Error("RemoveOrgUser(%d,%d): %v", org.ID, ctx.Doer.ID, err) + } + return + } + + if err != nil { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": false, + "err": err.Error(), + }) + return + } + + redirect := ctx.Org.OrgLink + "/members" + if ctx.Params(":action") == "leave" { + redirect = setting.AppSubURL + "/" + } + + ctx.JSONRedirect(redirect) +} diff --git a/routers/web/org/org.go b/routers/web/org/org.go new file mode 100644 index 0000000..dd3aab4 --- /dev/null +++ b/routers/web/org/org.go @@ -0,0 +1,80 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "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.title") + ctx.Data["DefaultOrgVisibilityMode"] = setting.Service.DefaultOrgVisibilityMode + if !ctx.Doer.CanCreateOrganization() { + ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("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.title") + + if !ctx.Doer.CanCreateOrganization() { + ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed"))) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplCreateOrg) + return + } + + org := &organization.Organization{ + Name: form.OrgName, + IsActive: true, + Type: user_model.UserTypeOrganization, + Visibility: form.Visibility, + RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess, + } + + if err := organization.CreateOrganization(ctx, org, ctx.Doer); err != nil { + ctx.Data["Err_OrgName"] = true + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.org_name_been_taken"), tplCreateOrg, &form) + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("org.form.name_reserved", err.(db.ErrNameReserved).Name), tplCreateOrg, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("org.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplCreateOrg, &form) + case organization.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.AsUser().DashboardLink()) +} diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go new file mode 100644 index 0000000..02eae80 --- /dev/null +++ b/routers/web/org/org_labels.go @@ -0,0 +1,116 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/label" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// RetrieveLabels find all the labels of an organization +func RetrieveLabels(ctx *context.Context) { + labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("RetrieveLabels.GetLabels", err) + return + } + for _, l := range labels { + l.CalOpenIssues() + } + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SortType"] = ctx.FormString("sort") +} + +// NewLabel create new label for organization +func NewLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsLabels"] = true + ctx.Data["PageIsOrgSettings"] = true + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + return + } + + l := &issues_model.Label{ + OrgID: ctx.Org.Organization.ID, + Name: form.Title, + Exclusive: form.Exclusive, + Description: form.Description, + Color: form.Color, + } + if err := issues_model.NewLabel(ctx, 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 := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, form.ID) + if err != nil { + switch { + case issues_model.IsErrOrgLabelNotExist(err): + ctx.Error(http.StatusNotFound) + default: + ctx.ServerError("UpdateLabel", err) + } + return + } + + l.Name = form.Title + l.Exclusive = form.Exclusive + l.Description = form.Description + l.Color = form.Color + l.SetArchived(form.IsArchived) + if err := issues_model.UpdateLabel(ctx, l); err != nil { + ctx.ServerError("UpdateLabel", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} + +// DeleteLabel delete a label +func DeleteLabel(ctx *context.Context) { + if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteLabel: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels") +} + +// InitializeLabels init labels for an organization +func InitializeLabels(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.InitializeLabelsForm) + if ctx.HasError() { + ctx.Redirect(ctx.Org.OrgLink + "/labels") + return + } + + if err := repo_module.InitializeLabels(ctx, ctx.Org.Organization.ID, form.TemplateName, true); err != nil { + if label.IsErrTemplateLoad(err) { + originalErr := err.(label.ErrTemplateLoad).OriginalError + ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + return + } + ctx.ServerError("InitializeLabels", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go new file mode 100644 index 0000000..64d233f --- /dev/null +++ b/routers/web/org/projects.go @@ -0,0 +1,610 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + attachment_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplProjects base.TplName = "org/projects/list" + tplProjectsNew base.TplName = "org/projects/new" + tplProjectsView base.TplName = "org/projects/view" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if unit.TypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableProjects", nil) + return + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.Data["Title"] = ctx.Tr("repo.projects") + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + keyword := ctx.FormTrim("q") + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + var projectType project_model.Type + if ctx.ContextUser.IsOrganization() { + projectType = project_model.TypeOrganization + } else { + projectType = project_model.TypeIndividual + } + projects, total, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + Type: projectType, + Title: keyword, + }) + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(!isShowClosed), + Type: projectType, + }) + if err != nil { + ctx.ServerError("CountProjects", err) + return + } + + if isShowClosed { + ctx.Data["OpenCount"] = opTotal + ctx.Data["ClosedCount"] = total + } else { + ctx.Data["OpenCount"] = total + ctx.Data["ClosedCount"] = opTotal + } + + ctx.Data["Projects"] = projects + shared_user.RenderUserHeader(ctx) + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + for _, project := range projects { + project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description) + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + numPages := 0 + if total > 0 { + numPages = (int(total) - 1/setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["PageIsViewProjects"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(http.StatusOK, tplProjects) +} + +func canWriteProjects(ctx *context.Context) bool { + if ctx.ContextUser.IsOrganization() { + return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) + } + return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID +} + +// RenderNewProject render creating a project page +func RenderNewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs() + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["PageIsViewProjects"] = true + ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() + ctx.Data["CancelLink"] = ctx.ContextUser.HomeLink() + "/-/projects" + shared_user.RenderUserHeader(ctx) + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// NewProjectPost creates a new project +func NewProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + shared_user.RenderUserHeader(ctx) + + if ctx.HasError() { + RenderNewProject(ctx) + return + } + + newProject := project_model.Project{ + OwnerID: ctx.ContextUser.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: form.TemplateType, + CardType: form.CardType, + } + + if ctx.ContextUser.IsOrganization() { + newProject.Type = project_model.TypeOrganization + } else { + newProject.Type = project_model.TypeIndividual + } + + if err := project_model.NewProject(ctx, &newProject); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.Context) { + var toClose bool + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects") + return + } + id := ctx.ParamsInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSONRedirect(fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), id)) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects") +} + +// RenderEditProject allows a project to be edited +func RenderEditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + + shared_user.RenderUserHeader(ctx) + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["projectID"] = p.ID + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + ctx.Data["redirect"] = ctx.FormString("redirect") + ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() + ctx.Data["card_type"] = p.CardType + ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), p.ID) + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + projectID := ctx.ParamsInt64(":id") + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), projectID) + + shared_user.RenderUserHeader(ctx) + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + p, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + p.CardType = form.CardType + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + if ctx.FormString("redirect") == "project" { + ctx.Redirect(p.Link(ctx)) + } else { + ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") + } +} + +// ViewProject renders the project with board view for a project +func ViewProject(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + if project.CardType != project_model.CardTypeTextOnly { + issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + issuesAttachmentMap[issue.ID] = issueAttachment + } + } + } + ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + + if len(referencedIDs) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + + project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description) + ctx.Data["LinkedPRs"] = linkedPrsMap + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["Project"] = project + ctx.Data["IssuesMap"] = issuesMap + ctx.Data["Columns"] = columns + shared_user.RenderUserHeader(ctx) + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplProjectsView) +} + +// DeleteProjectColumn allows for the deletion of a project column +func DeleteProjectColumn(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + pb, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + + if err := project_model.DeleteColumnByID(ctx, ctx.ParamsInt64(":columnID")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.JSONOK() +} + +// AddColumnToProjectPost allows a new column to be added to a project. +func AddColumnToProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + if err := project_model.NewColumn(ctx, &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + }); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSONOK() +} + +// CheckProjectColumnChangePermissions check permission +func CheckProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return nil, nil + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return nil, nil + } + if column.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + return project, column +} + +// EditProjectColumn allows a project column's to be updated +func EditProjectColumn(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + _, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSONOK() +} + +// SetDefaultProjectColumn set default column for uncategorized issues/pulls +func SetDefaultProjectColumn(ctx *context.Context) { + project, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.JSONOK() +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if column.ProjectID != project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + if _, err = movedIssues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go new file mode 100644 index 0000000..ab419cc --- /dev/null +++ b/routers/web/org/projects_test.go @@ -0,0 +1,28 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/routers/web/org" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestCheckProjectColumnChangePermissions(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/-/projects/4/4") + contexttest.LoadUser(t, ctx, 2) + ctx.ContextUser = ctx.Doer // user2 + ctx.SetParams(":id", "4") + ctx.SetParams(":columnID", "4") + + project, column := org.CheckProjectColumnChangePermissions(ctx) + assert.NotNil(t, project) + assert.NotNil(t, column) + assert.False(t, ctx.Written()) +} diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go new file mode 100644 index 0000000..0be734a --- /dev/null +++ b/routers/web/org/setting.go @@ -0,0 +1,258 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "net/url" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +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["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility + ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess + ctx.Data["ContextUser"] = ctx.ContextUser + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsOptions) +} + +// SettingsPost response for settings change submitted +func SettingsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateOrgSettingForm) + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsOptions) + return + } + + org := ctx.Org.Organization + + if org.Name != form.Name { + if err := user_service.RenameUser(ctx, org.AsUser(), form.Name); err != nil { + if user_model.IsErrUserAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) + } else if db.IsErrNameReserved(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) + } else if db.IsErrNamePatternNotAllowed(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + } else { + ctx.ServerError("RenameUser", err) + } + return + } + + ctx.Org.OrgLink = setting.AppSubURL + "/org/" + url.PathEscape(org.Name) + } + + if form.Email != "" { + if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), form.Email); err != nil { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsOptions, &form) + return + } + } + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + Description: optional.Some(form.Description), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Visibility: optional.Some(form.Visibility), + RepoAdminChangeTeamAccess: optional.Some(form.RepoAdminChangeTeamAccess), + } + if ctx.Doer.IsAdmin { + opts.MaxRepoCreation = optional.Some(form.MaxRepoCreation) + } + + visibilityChanged := org.Visibility != form.Visibility + + if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + // update forks visibility + if visibilityChanged { + repos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + Actor: org.AsUser(), Private: true, ListOptions: db.ListOptions{Page: 1, PageSize: org.NumRepos}, + }) + if err != nil { + ctx.ServerError("GetRepositories", err) + return + } + for _, repo := range repos { + repo.OwnerName = org.Name + if err := repo_service.UpdateRepository(ctx, repo, true); 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 := user_setting.UpdateAvatarSetting(ctx, form, ctx.Org.Organization.AsUser()); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("org.settings.update_avatar_success")) + } + + ctx.Redirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsDeleteAvatar response for delete avatar on settings page +func SettingsDeleteAvatar(ctx *context.Context) { + if err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser()); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsDelete response for deleting an organization +func SettingsDelete(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsDelete"] = true + + if ctx.Req.Method == "POST" { + if ctx.Org.Organization.Name != ctx.FormString("org_name") { + ctx.Data["Err_OrgName"] = true + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_org_name"), tplSettingsDelete, nil) + return + } + + if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { + if models.IsErrUserOwnRepos(err) { + ctx.Flash.Error(ctx.Tr("form.org_still_own_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") + } else if models.IsErrUserOwnPackages(err) { + ctx.Flash.Error(ctx.Tr("form.org_still_own_packages")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") + } else { + ctx.ServerError("DeleteOrganization", err) + } + } else { + log.Trace("Organization deleted: %s", ctx.Org.Organization.Name) + ctx.Redirect(setting.AppSubURL + "/") + } + return + } + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + 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["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = ctx.Org.OrgLink + "/settings/hooks" + ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks" + ctx.Data["WebhookList"] = webhook_service.List() + ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc") + + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Org.Organization.ID}) + if err != nil { + ctx.ServerError("ListWebhooksByOpts", err) + return + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.Data["Webhooks"] = ws + ctx.HTML(http.StatusOK, tplSettingsHooks) +} + +// DeleteWebhook response for delete webhook +func DeleteWebhook(ctx *context.Context) { + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/hooks") +} + +// Labels render organization labels page +func Labels(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsLabels"] = true + ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsLabels) +} diff --git a/routers/web/org/setting/blocked_users.go b/routers/web/org/setting/blocked_users.go new file mode 100644 index 0000000..0c7f245 --- /dev/null +++ b/routers/web/org/setting/blocked_users.go @@ -0,0 +1,85 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +const tplBlockedUsers = "org/settings/blocked_users" + +// BlockedUsers renders the blocked users page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsSettingsBlockedUsers"] = true + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + + ctx.HTML(http.StatusOK, tplBlockedUsers) +} + +// BlockedUsersBlock blocks a particular user from the organization. +func BlockedUsersBlock(ctx *context.Context) { + uname := strings.ToLower(ctx.FormString("uname")) + u, err := user_model.GetUserByName(ctx, uname) + if err != nil { + ctx.ServerError("GetUserByName", err) + return + } + + if u.IsOrganization() { + ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name)) + return + } + + if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil { + ctx.ServerError("BlockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_block_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users") +} + +// BlockedUsersUnblock unblocks a particular user from the organization. +func BlockedUsersUnblock(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.FormInt64("user_id")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + if u.IsOrganization() { + ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name)) + return + } + + if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil { + ctx.ServerError("UnblockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users") +} diff --git a/routers/web/org/setting/runners.go b/routers/web/org/setting/runners.go new file mode 100644 index 0000000..fe05709 --- /dev/null +++ b/routers/web/org/setting/runners.go @@ -0,0 +1,12 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/services/context" +) + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions/runners") +} diff --git a/routers/web/org/setting_oauth2.go b/routers/web/org/setting_oauth2.go new file mode 100644 index 0000000..7f85579 --- /dev/null +++ b/routers/web/org/setting_oauth2.go @@ -0,0 +1,102 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsApplications base.TplName = "org/settings/applications" + tplSettingsOAuthApplicationEdit base.TplName = "org/settings/applications_oauth2_edit" +) + +func newOAuth2CommonHandlers(org *context.Organization) *user_setting.OAuth2CommonHandlers { + return &user_setting.OAuth2CommonHandlers{ + OwnerID: org.Organization.ID, + BasePathList: fmt.Sprintf("%s/org/%s/settings/applications", setting.AppSubURL, org.Organization.Name), + BasePathEditPrefix: fmt.Sprintf("%s/org/%s/settings/applications/oauth2", setting.AppSubURL, org.Organization.Name), + TplAppEdit: tplSettingsOAuthApplicationEdit, + } +} + +// Applications render org applications page (for org, at the moment, there are only OAuth2 applications) +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{ + OwnerID: ctx.Org.Organization.ID, + }) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Applications"] = apps + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsApplications) +} + +// OAuthApplicationsPost response for adding an oauth2 application +func OAuthApplicationsPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.AddApp(ctx) +} + +// OAuth2ApplicationShow displays the given application +func OAuth2ApplicationShow(ctx *context.Context) { + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.EditShow(ctx) +} + +// OAuth2ApplicationEdit response for editing oauth2 application +func OAuth2ApplicationEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.EditSave(ctx) +} + +// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret +func OAuthApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.RegenerateSecret(ctx) +} + +// DeleteOAuth2Application deletes the given oauth2 application +func DeleteOAuth2Application(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Org) + oa.DeleteApp(ctx) +} + +// TODO: revokes the grant with the given id diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go new file mode 100644 index 0000000..af9836e --- /dev/null +++ b/routers/web/org/setting_packages.go @@ -0,0 +1,131 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared "code.gitea.io/gitea/routers/web/shared/packages" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsPackages base.TplName = "org/settings/packages" + tplSettingsPackagesRuleEdit base.TplName = "org/settings/packages_cleanup_rules_edit" + tplSettingsPackagesRulePreview base.TplName = "org/settings/packages_cleanup_rules_preview" +) + +func Packages(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetPackagesContext(ctx, ctx.ContextUser) + + ctx.HTML(http.StatusOK, tplSettingsPackages) +} + +func PackagesRuleAdd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetRuleAddContext(ctx) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetRuleEditContext(ctx, ctx.ContextUser) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleAddPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleAddPost( + ctx, + ctx.ContextUser, + fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name), + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRuleEditPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleEditPost( + ctx, + ctx.ContextUser, + fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name), + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRulePreview(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetRulePreviewContext(ctx, ctx.ContextUser) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview) +} + +func InitializeCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.InitializeCargoIndex(ctx, ctx.ContextUser) + + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} + +func RebuildCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.RebuildCargoIndex(ctx, ctx.ContextUser) + + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go new file mode 100644 index 0000000..45c3674 --- /dev/null +++ b/routers/web/org/teams.go @@ -0,0 +1,628 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" +) + +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" + // tplTeamInvite template path for team invites page + tplTeamInvite base.TplName = "org/team/invite" +) + +// 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 ctx.Org.Teams { + if err := t.LoadMembers(ctx); err != nil { + ctx.ServerError("GetMembers", err) + return + } + } + ctx.Data["Teams"] = ctx.Org.Teams + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplTeams) +} + +// TeamsAction response for join, leave, remove, add operations to team +func TeamsAction(ctx *context.Context) { + page := ctx.FormString("page") + var err error + switch ctx.Params(":action") { + case "join": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + case "leave": + err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + if err != nil { + if org_model.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]any{ + "ok": false, + "err": err.Error(), + }) + return + } + } + checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/") + return + case "remove": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + + uid := ctx.FormInt64("uid") + if uid == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/teams") + return + } + + err = models.RemoveTeamMember(ctx, ctx.Org.Team, uid) + if err != nil { + if org_model.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]any{ + "ok": false, + "err": err.Error(), + }) + return + } + } + checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/"+url.PathEscape(ctx.Org.Team.LowerName)) + return + case "add": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + uname := strings.ToLower(ctx.FormString("uname")) + var u *user_model.User + u, err = user_model.GetUserByName(ctx, uname) + if err != nil { + if user_model.IsErrUserNotExist(err) { + if setting.MailService != nil && user_model.ValidateEmail(uname) == nil { + if err := org_service.CreateTeamInvite(ctx, ctx.Doer, ctx.Org.Team, uname); err != nil { + if org_model.IsErrTeamInviteAlreadyExist(err) { + ctx.Flash.Error(ctx.Tr("form.duplicate_invite_to_team")) + } else if org_model.IsErrUserEmailAlreadyAdded(err) { + ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) + } else { + ctx.ServerError("CreateTeamInvite", err) + return + } + } + } else { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if u.IsOrganization() { + ctx.Flash.Error(ctx.Tr("form.cannot_add_org_to_team")) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + return + } + + if ctx.Org.Team.IsMember(ctx, u.ID) { + ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) + } else { + err = models.AddTeamMember(ctx, ctx.Org.Team, u.ID) + } + + page = "team" + case "remove_invite": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + + iid := ctx.FormInt64("iid") + if iid == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + return + } + + if err := org_model.RemoveInviteByID(ctx, iid, ctx.Org.Team.ID); err != nil { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.ServerError("RemoveInviteByID", err) + return + } + + page = "team" + } + + if err != nil { + if org_model.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + } else { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": false, + "err": err.Error(), + }) + return + } + } + + switch page { + case "team": + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + case "home": + ctx.Redirect(ctx.Org.Organization.AsUser().HomeLink()) + default: + ctx.Redirect(ctx.Org.OrgLink + "/teams") + } +} + +func checkIsOrgMemberAndRedirect(ctx *context.Context, defaultRedirect string) { + if isOrgMember, err := org_model.IsOrganizationMember(ctx, ctx.Org.Organization.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("IsOrganizationMember", err) + return + } else if !isOrgMember { + if ctx.Org.Organization.Visibility.IsPrivate() { + defaultRedirect = setting.AppSubURL + "/" + } else { + defaultRedirect = ctx.Org.Organization.HomeLink() + } + } + ctx.JSONRedirect(defaultRedirect) +} + +// TeamsRepoAction operate team's repository +func TeamsRepoAction(ctx *context.Context) { + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + + var err error + action := ctx.Params(":action") + switch action { + case "add": + repoName := path.Base(ctx.FormString("repo_name")) + var repo *repo_model.Repository + repo, err = repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.Flash.Error(ctx.Tr("org.teams.add_nonexistent_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories") + return + } + ctx.ServerError("GetRepositoryByName", err) + return + } + err = org_service.TeamAddRepository(ctx, ctx.Org.Team, repo) + case "remove": + err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid")) + case "addall": + err = models.AddAllRepositories(ctx, ctx.Org.Team) + case "removeall": + err = models.RemoveAllRepositories(ctx, ctx.Org.Team) + } + + 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.JSONRedirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories") + return + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories") +} + +// NewTeam render create new team page +func NewTeam(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamsNew"] = true + ctx.Data["Team"] = &org_model.Team{} + ctx.Data["Units"] = unit_model.Units + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.HTML(http.StatusOK, tplTeamNew) +} + +func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode { + unitPerms := make(map[unit_model.Type]perm.AccessMode) + for _, ut := range unit_model.AllRepoUnitTypes { + // Default accessmode is none + unitPerms[ut] = perm.AccessModeNone + + v, ok := forms[fmt.Sprintf("unit_%d", ut)] + if ok { + vv, _ := strconv.Atoi(v[0]) + if teamPermission >= perm.AccessModeAdmin { + unitPerms[ut] = teamPermission + // Don't allow `TypeExternal{Tracker,Wiki}` to influence this as they can only be set to READ perms. + if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki { + unitPerms[ut] = perm.AccessModeRead + } + } else { + unitPerms[ut] = perm.AccessMode(vv) + if unitPerms[ut] >= perm.AccessModeAdmin { + unitPerms[ut] = perm.AccessModeWrite + } + } + } + } + return unitPerms +} + +// NewTeamPost response for create new team +func NewTeamPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateTeamForm) + includesAllRepositories := form.RepoAccess == "all" + p := perm.ParseAccessMode(form.Permission) + unitPerms := getUnitPerms(ctx.Req.Form, p) + if p < perm.AccessModeAdmin { + // if p is less than admin accessmode, then it should be general accessmode, + // so we should calculate the minial accessmode from units accessmodes. + p = unit_model.MinUnitAccessMode(unitPerms) + } + + t := &org_model.Team{ + OrgID: ctx.Org.Organization.ID, + Name: form.TeamName, + Description: form.Description, + AccessMode: p, + IncludesAllRepositories: includesAllRepositories, + CanCreateOrgRepo: form.CanCreateOrgRepo, + } + + units := make([]*org_model.TeamUnit, 0, len(unitPerms)) + for tp, perm := range unitPerms { + units = append(units, &org_model.TeamUnit{ + OrgID: ctx.Org.Organization.ID, + Type: tp, + AccessMode: perm, + }) + } + t.Units = units + + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamsNew"] = true + ctx.Data["Units"] = unit_model.Units + ctx.Data["Team"] = t + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTeamNew) + return + } + + if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 { + ctx.RenderWithErr(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form) + return + } + + if err := models.NewTeam(ctx, t); err != nil { + ctx.Data["Err_TeamName"] = true + switch { + case org_model.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/" + url.PathEscape(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 := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + if err := ctx.Org.Team.LoadMembers(ctx); err != nil { + ctx.ServerError("GetMembers", err) + return + } + ctx.Data["Units"] = unit_model.Units + + invites, err := org_model.GetInvitesByTeamID(ctx, ctx.Org.Team.ID) + if err != nil { + ctx.ServerError("GetInvitesByTeamID", err) + return + } + ctx.Data["Invites"] = invites + ctx.Data["IsEmailInviteEnabled"] = setting.MailService != nil + + ctx.HTML(http.StatusOK, tplTeamMembers) +} + +// TeamRepositories show the repositories of team +func TeamRepositories(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Team.Name + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamRepos"] = true + + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + if err := ctx.Org.Team.LoadRepositories(ctx); err != nil { + ctx.ServerError("GetRepositories", err) + return + } + ctx.Data["Units"] = unit_model.Units + ctx.HTML(http.StatusOK, tplTeamRepositories) +} + +// SearchTeam api for searching teams +func SearchTeam(ctx *context.Context) { + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } + + opts := &org_model.SearchTeamOptions{ + // UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in + Keyword: ctx.FormTrim("q"), + OrgID: ctx.Org.Organization.ID, + IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"), + ListOptions: listOptions, + } + + teams, maxResults, err := org_model.SearchTeam(ctx, opts) + if err != nil { + log.Error("SearchTeam failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": "SearchTeam internal failure", + }) + return + } + + apiTeams, err := convert.ToTeams(ctx, teams, false) + if err != nil { + log.Error("convert ToTeams failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": "SearchTeam failed to get units", + }) + return + } + + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": true, + "data": apiTeams, + }) +} + +// EditTeam render team edit page +func EditTeam(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + if err := ctx.Org.Team.LoadUnits(ctx); err != nil { + ctx.ServerError("LoadUnits", err) + return + } + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.Data["Team"] = ctx.Org.Team + ctx.Data["Units"] = unit_model.Units + ctx.HTML(http.StatusOK, tplTeamNew) +} + +// EditTeamPost response for modify team information +func EditTeamPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateTeamForm) + t := ctx.Org.Team + newAccessMode := perm.ParseAccessMode(form.Permission) + unitPerms := getUnitPerms(ctx.Req.Form, newAccessMode) + if newAccessMode < perm.AccessModeAdmin { + // if newAccessMode is less than admin accessmode, then it should be general accessmode, + // so we should calculate the minial accessmode from units accessmodes. + newAccessMode = unit_model.MinUnitAccessMode(unitPerms) + } + isAuthChanged := false + isIncludeAllChanged := false + includesAllRepositories := form.RepoAccess == "all" + + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["Team"] = t + ctx.Data["Units"] = unit_model.Units + + if !t.IsOwnerTeam() { + t.Name = form.TeamName + if t.AccessMode != newAccessMode { + isAuthChanged = true + t.AccessMode = newAccessMode + } + + if t.IncludesAllRepositories != includesAllRepositories { + isIncludeAllChanged = true + t.IncludesAllRepositories = includesAllRepositories + } + t.CanCreateOrgRepo = form.CanCreateOrgRepo + + units := make([]*org_model.TeamUnit, 0, len(unitPerms)) + for tp, perm := range unitPerms { + units = append(units, &org_model.TeamUnit{ + OrgID: t.OrgID, + TeamID: t.ID, + Type: tp, + AccessMode: perm, + }) + } + t.Units = units + } else { + t.CanCreateOrgRepo = true + } + + t.Description = form.Description + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTeamNew) + return + } + + if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 { + ctx.RenderWithErr(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form) + return + } + + if err := models.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil { + ctx.Data["Err_TeamName"] = true + switch { + case org_model.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/" + url.PathEscape(t.LowerName)) +} + +// DeleteTeam response for the delete team request +func DeleteTeam(ctx *context.Context) { + if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil { + ctx.Flash.Error("DeleteTeam: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success")) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/teams") +} + +// TeamInvite renders the team invite page +func TeamInvite(ctx *context.Context) { + invite, org, team, inviter, err := getTeamInviteFromContext(ctx) + if err != nil { + if org_model.IsErrTeamInviteNotFound(err) { + ctx.NotFound("ErrTeamInviteNotFound", err) + } else { + ctx.ServerError("getTeamInviteFromContext", err) + } + return + } + + ctx.Data["Title"] = ctx.Tr("org.teams.invite_team_member", team.Name) + ctx.Data["Invite"] = invite + ctx.Data["Organization"] = org + ctx.Data["Team"] = team + ctx.Data["Inviter"] = inviter + + ctx.HTML(http.StatusOK, tplTeamInvite) +} + +// TeamInvitePost handles the team invitation +func TeamInvitePost(ctx *context.Context) { + invite, org, team, _, err := getTeamInviteFromContext(ctx) + if err != nil { + if org_model.IsErrTeamInviteNotFound(err) { + ctx.NotFound("ErrTeamInviteNotFound", err) + } else { + ctx.ServerError("getTeamInviteFromContext", err) + } + return + } + + if err := models.AddTeamMember(ctx, team, ctx.Doer.ID); err != nil { + ctx.ServerError("AddTeamMember", err) + return + } + + if err := org_model.RemoveInviteByID(ctx, invite.ID, team.ID); err != nil { + log.Error("RemoveInviteByID: %v", err) + } + + ctx.Redirect(org.OrganisationLink() + "/teams/" + url.PathEscape(team.LowerName)) +} + +func getTeamInviteFromContext(ctx *context.Context) (*org_model.TeamInvite, *org_model.Organization, *org_model.Team, *user_model.User, error) { + invite, err := org_model.GetInviteByToken(ctx, ctx.Params("token")) + if err != nil { + return nil, nil, nil, nil, err + } + + inviter, err := user_model.GetUserByID(ctx, invite.InviterID) + if err != nil { + return nil, nil, nil, nil, err + } + + team, err := org_model.GetTeamByID(ctx, invite.TeamID) + if err != nil { + return nil, nil, nil, nil, err + } + + org, err := user_model.GetUserByID(ctx, team.OrgID) + if err != nil { + return nil, nil, nil, nil, err + } + + return invite, org_model.OrgFromUser(org), team, inviter, nil +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go new file mode 100644 index 0000000..ff3b161 --- /dev/null +++ b/routers/web/repo/actions/actions.go @@ -0,0 +1,247 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "bytes" + "fmt" + "net/http" + "slices" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + + "github.com/nektos/act/pkg/model" +) + +const ( + tplListActions base.TplName = "repo/actions/list" + tplViewActions base.TplName = "repo/actions/view" +) + +type Workflow struct { + Entry git.TreeEntry + ErrMsg string +} + +// MustEnableActions check if actions are enabled in settings +func MustEnableActions(ctx *context.Context) { + if !setting.Actions.Enabled { + ctx.NotFound("MustEnableActions", nil) + return + } + + if unit.TypeActions.UnitGlobalDisabled() { + ctx.NotFound("MustEnableActions", nil) + return + } + + if ctx.Repo.Repository != nil { + if !ctx.Repo.CanRead(unit.TypeActions) { + ctx.NotFound("MustEnableActions", nil) + return + } + } +} + +func List(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageIsActions"] = true + + curWorkflow := ctx.FormString("workflow") + ctx.Data["CurWorkflow"] = curWorkflow + + var workflows []Workflow + if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { + ctx.ServerError("IsEmpty", err) + return + } else if !empty { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.ServerError("GetBranchCommit", err) + return + } + entries, err := actions.ListWorkflows(commit) + if err != nil { + ctx.ServerError("ListWorkflows", err) + return + } + + // Get all runner labels + runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ + RepoID: ctx.Repo.Repository.ID, + IsOnline: optional.Some(true), + WithAvailable: true, + }) + if err != nil { + ctx.ServerError("FindRunners", err) + return + } + allRunnerLabels := make(container.Set[string]) + for _, r := range runners { + allRunnerLabels.AddMultiple(r.AgentLabels...) + } + + canRun := ctx.Repo.CanWrite(unit.TypeActions) + + workflows = make([]Workflow, 0, len(entries)) + for _, entry := range entries { + workflow := Workflow{Entry: *entry} + content, err := actions.GetContentFromEntry(entry) + if err != nil { + ctx.ServerError("GetContentFromEntry", err) + return + } + wf, err := model.ReadWorkflow(bytes.NewReader(content)) + if err != nil { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + workflows = append(workflows, workflow) + continue + } + // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. + hasJobWithoutNeeds := false + // Check whether have matching runner and a job without "needs" + emptyJobsNumber := 0 + for _, j := range wf.Jobs { + if j == nil { + emptyJobsNumber++ + continue + } + if !hasJobWithoutNeeds && len(j.Needs()) == 0 { + hasJobWithoutNeeds = true + } + runsOnList := j.RunsOn() + for _, ro := range runsOnList { + if strings.Contains(ro, "${{") { + // Skip if it contains expressions. + // The expressions could be very complex and could not be evaluated here, + // so just skip it, it's OK since it's just a tooltip message. + continue + } + if !allRunnerLabels.Contains(ro) { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro) + break + } + } + if workflow.ErrMsg != "" { + break + } + } + if !hasJobWithoutNeeds { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs") + } + if emptyJobsNumber == len(wf.Jobs) { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") + } + workflows = append(workflows, workflow) + + if canRun && workflow.Entry.Name() == curWorkflow { + config := wf.WorkflowDispatchConfig() + if config != nil { + keys := util.KeysOfMap(config.Inputs) + slices.Sort(keys) + if int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs { + keys = keys[:setting.Actions.LimitDispatchInputs] + } + + ctx.Data["CurWorkflowDispatch"] = config + ctx.Data["CurWorkflowDispatchInputKeys"] = keys + ctx.Data["WarnDispatchInputsLimit"] = int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs + ctx.Data["DispatchInputsLimit"] = setting.Actions.LimitDispatchInputs + } + } + } + } + ctx.Data["workflows"] = workflows + ctx.Data["RepoLink"] = ctx.Repo.Repository.Link() + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + actorID := ctx.FormInt64("actor") + status := ctx.FormInt("status") + + actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + ctx.Data["ActionsConfig"] = actionsConfig + + if len(curWorkflow) > 0 && ctx.Repo.IsAdmin() { + ctx.Data["AllowDisableOrEnableWorkflow"] = true + ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflow) + } + + // if status or actor query param is not given to frontend href, (href="/<repoLink>/actions") + // they will be 0 by default, which indicates get all status or actors + ctx.Data["CurActor"] = actorID + ctx.Data["CurStatus"] = status + if actorID > 0 || status > int(actions_model.StatusUnknown) { + ctx.Data["IsFiltered"] = true + } + + opts := actions_model.FindRunOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: curWorkflow, + TriggerUserID: actorID, + } + + // if status is not StatusUnknown, it means user has selected a status filter + if actions_model.Status(status) != actions_model.StatusUnknown { + opts.Status = []actions_model.Status{actions_model.Status(status)} + } + + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + if err != nil { + ctx.ServerError("FindAndCount", err) + return + } + + for _, run := range runs { + run.Repo = ctx.Repo.Repository + } + + if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { + ctx.ServerError("LoadTriggerUser", err) + return + } + + ctx.Data["Runs"] = runs + + ctx.Data["Repo"] = ctx.Repo + + actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetActors", err) + return + } + ctx.Data["Actors"] = repo.MakeSelfOnTop(ctx.Doer, actors) + + ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx) + + pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + pager.AddParamString("workflow", curWorkflow) + pager.AddParamString("actor", fmt.Sprint(actorID)) + pager.AddParamString("status", fmt.Sprint(status)) + ctx.Data["Page"] = pager + ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0 + + ctx.HTML(http.StatusOK, tplListActions) +} diff --git a/routers/web/repo/actions/manual.go b/routers/web/repo/actions/manual.go new file mode 100644 index 0000000..949469f --- /dev/null +++ b/routers/web/repo/actions/manual.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Forgejo Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "net/url" + + actions_service "code.gitea.io/gitea/services/actions" + context_module "code.gitea.io/gitea/services/context" +) + +func ManualRunWorkflow(ctx *context_module.Context) { + workflowID := ctx.FormString("workflow") + if len(workflowID) == 0 { + ctx.ServerError("workflow", nil) + return + } + + ref := ctx.FormString("ref") + if len(ref) == 0 { + ctx.ServerError("ref", nil) + return + } + + if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { + ctx.ServerError("IsEmpty", err) + return + } else if empty { + ctx.NotFound("IsEmpty", nil) + return + } + + workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, ref, workflowID) + if err != nil { + ctx.ServerError("GetWorkflowFromCommit", err) + return + } + + location := ctx.Repo.RepoLink + "/actions?workflow=" + url.QueryEscape(workflowID) + + "&actor=" + url.QueryEscape(ctx.FormString("actor")) + + "&status=" + url.QueryEscape(ctx.FormString("status")) + + formKeyGetter := func(key string) string { + formKey := "inputs[" + key + "]" + return ctx.Req.PostFormValue(formKey) + } + + if err := workflow.Dispatch(ctx, formKeyGetter, ctx.Repo.Repository, ctx.Doer); err != nil { + if actions_service.IsInputRequiredErr(err) { + ctx.Flash.Error(ctx.Locale.Tr("actions.workflow.dispatch.input_required", err.(actions_service.InputRequiredErr).Name)) + ctx.Redirect(location) + return + } + ctx.ServerError("workflow.Dispatch", err) + return + } + + // forward to the page of the run which was just created + ctx.Flash.Info(ctx.Locale.Tr("actions.workflow.dispatch.success")) + ctx.Redirect(location) +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go new file mode 100644 index 0000000..bc1ecbf --- /dev/null +++ b/routers/web/repo/actions/view.go @@ -0,0 +1,781 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "archive/zip" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + actions_service "code.gitea.io/gitea/services/actions" + context_module "code.gitea.io/gitea/services/context" + + "xorm.io/builder" +) + +func View(ctx *context_module.Context) { + ctx.Data["PageIsActions"] = true + runIndex := ctx.ParamsInt64("run") + jobIndex := ctx.ParamsInt64("job") + + job, _ := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + + workflowName := job.Run.WorkflowID + + ctx.Data["RunIndex"] = runIndex + ctx.Data["JobIndex"] = jobIndex + ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" + ctx.Data["WorkflowName"] = workflowName + ctx.Data["WorkflowURL"] = ctx.Repo.RepoLink + "/actions?workflow=" + workflowName + + ctx.HTML(http.StatusOK, tplViewActions) +} + +func ViewLatest(ctx *context_module.Context) { + run, err := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.NotFound("GetLatestRun", err) + return + } + err = run.LoadAttributes(ctx) + if err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Redirect(run.HTMLURL(), http.StatusTemporaryRedirect) +} + +func ViewLatestWorkflowRun(ctx *context_module.Context) { + branch := ctx.FormString("branch") + if branch == "" { + branch = ctx.Repo.Repository.DefaultBranch + } + branch = fmt.Sprintf("refs/heads/%s", branch) + event := ctx.FormString("event") + + workflowFile := ctx.Params("workflow_name") + run, err := actions_model.GetLatestRunForBranchAndWorkflow(ctx, ctx.Repo.Repository.ID, branch, workflowFile, event) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound("GetLatestRunForBranchAndWorkflow", err) + } else { + ctx.ServerError("GetLatestRunForBranchAndWorkflow", err) + } + return + } + + err = run.LoadAttributes(ctx) + if err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Redirect(run.HTMLURL(), http.StatusTemporaryRedirect) +} + +type ViewRequest struct { + LogCursors []struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Expanded bool `json:"expanded"` + } `json:"logCursors"` +} + +type ViewResponse struct { + State struct { + Run struct { + Link string `json:"link"` + Title string `json:"title"` + Status string `json:"status"` + CanCancel bool `json:"canCancel"` + CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve + CanRerun bool `json:"canRerun"` + CanDeleteArtifact bool `json:"canDeleteArtifact"` + Done bool `json:"done"` + Jobs []*ViewJob `json:"jobs"` + Commit ViewCommit `json:"commit"` + } `json:"run"` + CurrentJob struct { + Title string `json:"title"` + Detail string `json:"detail"` + Steps []*ViewJobStep `json:"steps"` + } `json:"currentJob"` + } `json:"state"` + Logs struct { + StepsLog []*ViewStepLog `json:"stepsLog"` + } `json:"logs"` +} + +type ViewJob struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CanRerun bool `json:"canRerun"` + Duration string `json:"duration"` +} + +type ViewCommit struct { + LocaleCommit string `json:"localeCommit"` + LocalePushedBy string `json:"localePushedBy"` + LocaleWorkflow string `json:"localeWorkflow"` + ShortSha string `json:"shortSHA"` + Link string `json:"link"` + Pusher ViewUser `json:"pusher"` + Branch ViewBranch `json:"branch"` +} + +type ViewUser struct { + DisplayName string `json:"displayName"` + Link string `json:"link"` +} + +type ViewBranch struct { + Name string `json:"name"` + Link string `json:"link"` +} + +type ViewJobStep struct { + Summary string `json:"summary"` + Duration string `json:"duration"` + Status string `json:"status"` +} + +type ViewStepLog struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Lines []*ViewStepLogLine `json:"lines"` + Started int64 `json:"started"` +} + +type ViewStepLogLine struct { + Index int64 `json:"index"` + Message string `json:"message"` + Timestamp float64 `json:"timestamp"` +} + +func ViewPost(ctx *context_module.Context) { + req := web.GetForm(ctx).(*ViewRequest) + runIndex := ctx.ParamsInt64("run") + jobIndex := ctx.ParamsInt64("job") + + current, jobs := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + run := current.Run + if err := run.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + resp := &ViewResponse{} + + resp.State.Run.Title = run.Title + resp.State.Run.Link = run.Link() + resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.Done = run.Status.IsDone() + resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead of 'null' in json + resp.State.Run.Status = run.Status.String() + for _, v := range jobs { + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ + ID: v.ID, + Name: v.Name, + Status: v.Status.String(), + CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions), + Duration: v.Duration().String(), + }) + } + + pusher := ViewUser{ + DisplayName: run.TriggerUser.GetDisplayName(), + Link: run.TriggerUser.HomeLink(), + } + branch := ViewBranch{ + Name: run.PrettyRef(), + Link: run.RefLink(), + } + resp.State.Run.Commit = ViewCommit{ + LocaleCommit: ctx.Locale.TrString("actions.runs.commit"), + LocalePushedBy: ctx.Locale.TrString("actions.runs.pushed_by"), + LocaleWorkflow: ctx.Locale.TrString("actions.runs.workflow"), + ShortSha: base.ShortSha(run.CommitSHA), + Link: fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA), + Pusher: pusher, + Branch: branch, + } + + var task *actions_model.ActionTask + if current.TaskID > 0 { + var err error + task, err = actions_model.GetTaskByID(ctx, current.TaskID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + task.Job = current + if err := task.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + resp.State.CurrentJob.Title = current.Name + resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) + if run.NeedApproval { + resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc") + } + resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead of 'null' in json + resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead of 'null' in json + if task != nil { + steps := actions.FullSteps(task) + + for _, v := range steps { + resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{ + Summary: v.Name, + Duration: v.Duration().String(), + Status: v.Status.String(), + }) + } + + for _, cursor := range req.LogCursors { + if !cursor.Expanded { + continue + } + + step := steps[cursor.Step] + + // if task log is expired, return a consistent log line + if task.LogExpired { + if cursor.Cursor == 0 { + resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ + Step: cursor.Step, + Cursor: 1, + Lines: []*ViewStepLogLine{ + { + Index: 1, + Message: ctx.Locale.TrString("actions.runs.expire_log_message"), + // Timestamp doesn't mean anything when the log is expired. + // Set it to the task's updated time since it's probably the time when the log has expired. + Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), + }, + }, + Started: int64(step.Started), + }) + } + continue + } + + logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead of 'null' in json + + index := step.LogIndex + cursor.Cursor + validCursor := cursor.Cursor >= 0 && + // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready. + // So return the same cursor and empty lines to let the frontend retry. + cursor.Cursor < step.LogLength && + // !(index < task.LogIndexes[index]) when task data is older than step data. + // It can be fixed by making sure write/read tasks and steps in the same transaction, + // but it's easier to just treat it as fetching the next line before it's ready. + index < int64(len(task.LogIndexes)) + + if validCursor { + length := step.LogLength - cursor.Cursor + offset := task.LogIndexes[index] + var err error + logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + for i, row := range logRows { + logLines = append(logLines, &ViewStepLogLine{ + Index: cursor.Cursor + int64(i) + 1, // start at 1 + Message: row.Content, + Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), + }) + } + } + + resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ + Step: cursor.Step, + Cursor: cursor.Cursor + int64(len(logLines)), + Lines: logLines, + Started: int64(step.Started), + }) + } + } + + ctx.JSON(http.StatusOK, resp) +} + +// Rerun will rerun jobs in the given run +// If jobIndexStr is a blank string, it means rerun all jobs +func Rerun(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + jobIndexStr := ctx.Params("job") + var jobIndex int64 + if jobIndexStr != "" { + jobIndex, _ = strconv.ParseInt(jobIndexStr, 10, 64) + } + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + // can not rerun job when workflow is disabled + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled")) + return + } + + // reset run's start and stop time when it is done + if run.Status.IsDone() { + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + job, jobs := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + + if jobIndexStr == "" { // rerun all jobs + for _, j := range jobs { + // if the job has needs, it should be set to "blocked" status to wait for other jobs + shouldBlock := len(j.Needs) > 0 + if err := rerunJob(ctx, j, shouldBlock); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + ctx.JSON(http.StatusOK, struct{}{}) + return + } + + rerunJobs := actions_service.GetAllRerunJobs(job, jobs) + + for _, j := range rerunJobs { + // jobs other than the specified one should be set to "blocked" status + shouldBlock := j.JobID != job.JobID + if err := rerunJob(ctx, j, shouldBlock); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + ctx.JSON(http.StatusOK, struct{}{}) +} + +func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { + status := job.Status + if !status.IsDone() { + return nil + } + + job.TaskID = 0 + job.Status = actions_model.StatusWaiting + if shouldBlock { + job.Status = actions_model.StatusBlocked + } + job.Started = 0 + job.Stopped = 0 + + if err := db.WithTx(ctx, func(ctx context.Context) error { + _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "task_id", "status", "started", "stopped") + return err + }); err != nil { + return err + } + + actions_service.CreateCommitStatus(ctx, job) + return nil +} + +func Logs(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + jobIndex := ctx.ParamsInt64("job") + + job, _ := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + if job.TaskID == 0 { + ctx.Error(http.StatusNotFound, "job is not started") + return + } + + err := job.LoadRun(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + task, err := actions_model.GetTaskByID(ctx, job.TaskID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if task.LogExpired { + ctx.Error(http.StatusNotFound, "logs have been cleaned up") + return + } + + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + defer reader.Close() + + workflowName := job.Run.WorkflowID + if p := strings.Index(workflowName, "."); p > 0 { + workflowName = workflowName[0:p] + } + ctx.ServeContent(reader, &context_module.ServeHeaderOptions{ + Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, job.Name, task.ID), + ContentLength: &task.LogSize, + ContentType: "text/plain", + ContentTypeCharset: "utf-8", + Disposition: "attachment", + }) +} + +func Cancel(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + + _, jobs := getRunJobs(ctx, runIndex, -1) + if ctx.Written() { + return + } + + if err := db.WithTx(ctx, func(ctx context.Context) error { + for _, job := range jobs { + status := job.Status + if status.IsDone() { + continue + } + if job.TaskID == 0 { + job.Status = actions_model.StatusCancelled + job.Stopped = timeutil.TimeStampNow() + n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("job has changed, try again") + } + continue + } + if err := actions_model.StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil { + return err + } + } + return nil + }); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + actions_service.CreateCommitStatus(ctx, jobs...) + + ctx.JSON(http.StatusOK, struct{}{}) +} + +func Approve(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + + current, jobs := getRunJobs(ctx, runIndex, -1) + if ctx.Written() { + return + } + run := current.Run + doer := ctx.Doer + + if err := db.WithTx(ctx, func(ctx context.Context) error { + run.NeedApproval = false + run.ApprovedBy = doer.ID + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return err + } + for _, job := range jobs { + if len(job.Needs) == 0 && job.Status.IsBlocked() { + job.Status = actions_model.StatusWaiting + _, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + } + } + return nil + }); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + actions_service.CreateCommitStatus(ctx, jobs...) + + ctx.JSON(http.StatusOK, struct{}{}) +} + +// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs. +// Any error will be written to the ctx. +// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0. +func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) { + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return nil, nil + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return nil, nil + } + run.Repo = ctx.Repo.Repository + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return nil, nil + } + if len(jobs) == 0 { + ctx.Error(http.StatusNotFound) + return nil, nil + } + + for _, v := range jobs { + v.Run = run + } + + if jobIndex >= 0 && jobIndex < int64(len(jobs)) { + return jobs[jobIndex], jobs + } + return jobs[0], jobs +} + +type ArtifactsViewResponse struct { + Artifacts []*ArtifactsViewItem `json:"artifacts"` +} + +type ArtifactsViewItem struct { + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` +} + +func ArtifactsView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + artifactsResponse := ArtifactsViewResponse{ + Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)), + } + for _, art := range artifacts { + status := "completed" + if art.Status == actions_model.ArtifactStatusExpired { + status = "expired" + } + artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{ + Name: art.ArtifactName, + Size: art.FileSize, + Status: status, + }) + } + ctx.JSON(http.StatusOK, artifactsResponse) +} + +func ArtifactsDeleteView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + artifactName := ctx.Params("artifact_name") + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + ctx.JSON(http.StatusOK, struct{}{}) +} + +func ArtifactsDownloadView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + artifactName := ctx.Params("artifact_name") + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RunID: run.ID, + ArtifactName: artifactName, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if len(artifacts) == 0 { + ctx.Error(http.StatusNotFound, "artifact not found") + return + } + + // if artifacts status is not uploaded-confirmed, treat it as not found + for _, art := range artifacts { + if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) { + ctx.Error(http.StatusNotFound, "artifact not found") + return + } + } + + // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend + // The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend + if len(artifacts) == 1 && artifacts[0].ArtifactName+".zip" == artifacts[0].ArtifactPath && artifacts[0].ContentEncoding == "application/zip" { + art := artifacts[0] + if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { + u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath) + if u != nil && err == nil { + ctx.Redirect(u.String()) + return + } + } + f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + common.ServeContentByReadSeeker(ctx.Base, artifactName, util.ToPointer(art.UpdatedUnix.AsTime()), f) + return + } + + // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend + // Those need to be zipped for download + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) + writer := zip.NewWriter(ctx.Resp) + defer writer.Close() + for _, art := range artifacts { + f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + var r io.ReadCloser + if art.ContentEncoding == "gzip" { + r, err = gzip.NewReader(f) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } else { + r = f + } + defer r.Close() + + w, err := writer.Create(art.ArtifactPath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if _, err := io.Copy(w, r); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } +} + +func DisableWorkflowFile(ctx *context_module.Context) { + disableOrEnableWorkflowFile(ctx, false) +} + +func EnableWorkflowFile(ctx *context_module.Context) { + disableOrEnableWorkflowFile(ctx, true) +} + +func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { + workflow := ctx.FormString("workflow") + if len(workflow) == 0 { + ctx.ServerError("workflow", nil) + return + } + + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + + if isEnable { + cfg.EnableWorkflow(workflow) + } else { + cfg.DisableWorkflow(workflow) + } + + if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil { + ctx.ServerError("UpdateRepoUnit", err) + return + } + + if isEnable { + ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow)) + } else { + ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow)) + } + + redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(workflow), + url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status"))) + ctx.JSONRedirect(redirectURL) +} diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go new file mode 100644 index 0000000..ba776c8 --- /dev/null +++ b/routers/web/repo/activity.go @@ -0,0 +1,105 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/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["PageIsPulse"] = 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.UTC().Format(time.RFC3339) + ctx.Data["DateUntil"] = timeUntil.UTC().Format(time.RFC3339) + ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string)) + + var err error + if ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom, + ctx.Repo.CanRead(unit.TypeReleases), + ctx.Repo.CanRead(unit.TypeIssues), + ctx.Repo.CanRead(unit.TypePullRequests), + ctx.Repo.CanRead(unit.TypeCode) && !ctx.Repo.Repository.IsEmpty); err != nil { + ctx.ServerError("GetActivityStats", err) + return + } + + if ctx.PageData["repoActivityTopAuthors"], err = activities_model.GetActivityStatsTopAuthors(ctx, ctx.Repo.Repository, timeFrom, 10); err != nil { + ctx.ServerError("GetActivityStatsTopAuthors", err) + return + } + + ctx.HTML(http.StatusOK, tplActivity) +} + +// ActivityAuthors renders JSON with top commit authors for given time period over all branches +func ActivityAuthors(ctx *context.Context) { + timeUntil := time.Now() + var timeFrom time.Time + + switch ctx.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 := activities_model.GetActivityStatsTopAuthors(ctx, 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 0000000..b42effd --- /dev/null +++ b/routers/web/repo/attachment.go @@ -0,0 +1,163 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "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/util" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + repo_service "code.gitea.io/gitea/services/repository" +) + +// UploadIssueAttachment response for Issue/PR attachments +func UploadIssueAttachment(ctx *context.Context) { + uploadAttachment(ctx, ctx.Repo.Repository.ID, setting.Attachment.AllowedTypes) +} + +// UploadReleaseAttachment response for uploading release attachments +func UploadReleaseAttachment(ctx *context.Context) { + uploadAttachment(ctx, ctx.Repo.Repository.ID, setting.Repository.Release.AllowedTypes) +} + +// UploadAttachment response for uploading attachments +func uploadAttachment(ctx *context.Context, repoID int64, 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() + + attach, err := attachment.UploadAttachment(ctx, file, allowedTypes, header.Size, &repo_model.Attachment{ + Name: header.Filename, + UploaderID: ctx.Doer.ID, + RepoID: repoID, + }) + if err != nil { + if upload.IsErrFileTypeForbidden(err) { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + 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.FormString("file") + attach, err := repo_model.GetAttachmentByUUID(ctx, file) + if err != nil { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + if !ctx.IsSigned || (ctx.Doer.ID != attach.UploaderID) { + ctx.Error(http.StatusForbidden) + return + } + err = repo_model.DeleteAttachment(ctx, 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 attachments with the given UUID +func ServeAttachment(ctx *context.Context, uuid string) { + attach, err := repo_model.GetAttachmentByUUID(ctx, uuid) + if err != nil { + if repo_model.IsErrAttachmentNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.ServerError("GetAttachmentByUUID", err) + } + return + } + + repository, unitType, err := repo_service.LinkedRepository(ctx, attach) + if err != nil { + ctx.ServerError("LinkedRepository", err) + return + } + + if repository == nil { // If not linked + if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader + ctx.Error(http.StatusNotFound) + return + } + } else { // If we have the repository we check access + perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) + return + } + if !perm.CanRead(unitType) { + ctx.Error(http.StatusNotFound) + return + } + } + + if attach.ExternalURL != "" { + ctx.Redirect(attach.ExternalURL) + return + } + + if err := attach.IncreaseDownloadCount(ctx); err != nil { + ctx.ServerError("IncreaseDownloadCount", err) + return + } + + if setting.Attachment.Storage.MinioConfig.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() + + common.ServeContentByReadSeeker(ctx.Base, attach.Name, util.ToPointer(attach.CreatedUnix.AsTime()), fr) +} + +// GetAttachment serve attachments +func GetAttachment(ctx *context.Context) { + ServeAttachment(ctx, ctx.Params(":uuid")) +} diff --git a/routers/web/repo/badges/badges.go b/routers/web/repo/badges/badges.go new file mode 100644 index 0000000..a2306d5 --- /dev/null +++ b/routers/web/repo/badges/badges.go @@ -0,0 +1,164 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package badges + +import ( + "fmt" + "net/url" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/setting" + context_module "code.gitea.io/gitea/services/context" +) + +func getBadgeURL(ctx *context_module.Context, label, text, color string) string { + sb := &strings.Builder{} + _ = setting.Badges.GeneratorURLTemplateTemplate.Execute(sb, map[string]string{ + "label": url.PathEscape(strings.ReplaceAll(label, "-", "--")), + "text": url.PathEscape(strings.ReplaceAll(text, "-", "--")), + "color": url.PathEscape(color), + }) + + badgeURL := sb.String() + q := ctx.Req.URL.Query() + // Remove any `branch` or `event` query parameters. They're used by the + // workflow badge route, and do not need forwarding to the badge generator. + delete(q, "branch") + delete(q, "event") + if len(q) > 0 { + return fmt.Sprintf("%s?%s", badgeURL, q.Encode()) + } + return badgeURL +} + +func redirectToBadge(ctx *context_module.Context, label, text, color string) { + ctx.Redirect(getBadgeURL(ctx, label, text, color)) +} + +func errorBadge(ctx *context_module.Context, label, text string) { //nolint:unparam + ctx.Redirect(getBadgeURL(ctx, label, text, "crimson")) +} + +func GetWorkflowBadge(ctx *context_module.Context) { + branch := ctx.Req.URL.Query().Get("branch") + if branch != "" { + branch = fmt.Sprintf("refs/heads/%s", branch) + } + event := ctx.Req.URL.Query().Get("event") + + workflowFile := ctx.Params("workflow_name") + run, err := actions_model.GetLatestRunForBranchAndWorkflow(ctx, ctx.Repo.Repository.ID, branch, workflowFile, event) + if err != nil { + errorBadge(ctx, workflowFile, "Not found") + return + } + + var color string + switch run.Status { + case actions_model.StatusUnknown: + color = "lightgrey" + case actions_model.StatusWaiting: + color = "lightgrey" + case actions_model.StatusRunning: + color = "gold" + case actions_model.StatusSuccess: + color = "brightgreen" + case actions_model.StatusFailure: + color = "crimson" + case actions_model.StatusCancelled: + color = "orange" + case actions_model.StatusSkipped: + color = "blue" + case actions_model.StatusBlocked: + color = "yellow" + default: + color = "lightgrey" + } + + redirectToBadge(ctx, workflowFile, run.Status.String(), color) +} + +func getIssueOrPullBadge(ctx *context_module.Context, label, variant string, num int) { + var text string + if len(variant) > 0 { + text = fmt.Sprintf("%d %s", num, variant) + } else { + text = fmt.Sprintf("%d", num) + } + redirectToBadge(ctx, label, text, "blue") +} + +func getIssueBadge(ctx *context_module.Context, variant string, num int) { + if !ctx.Repo.CanRead(unit.TypeIssues) && + !ctx.Repo.CanRead(unit.TypeExternalTracker) { + errorBadge(ctx, "issues", "Not found") + return + } + + _, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) + if err == nil { + errorBadge(ctx, "issues", "Not found") + return + } + + getIssueOrPullBadge(ctx, "issues", variant, num) +} + +func getPullBadge(ctx *context_module.Context, variant string, num int) { + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + errorBadge(ctx, "pulls", "Not found") + return + } + + getIssueOrPullBadge(ctx, "pulls", variant, num) +} + +func GetOpenIssuesBadge(ctx *context_module.Context) { + getIssueBadge(ctx, "open", ctx.Repo.Repository.NumOpenIssues) +} + +func GetClosedIssuesBadge(ctx *context_module.Context) { + getIssueBadge(ctx, "closed", ctx.Repo.Repository.NumClosedIssues) +} + +func GetTotalIssuesBadge(ctx *context_module.Context) { + getIssueBadge(ctx, "", ctx.Repo.Repository.NumIssues) +} + +func GetOpenPullsBadge(ctx *context_module.Context) { + getPullBadge(ctx, "open", ctx.Repo.Repository.NumOpenPulls) +} + +func GetClosedPullsBadge(ctx *context_module.Context) { + getPullBadge(ctx, "closed", ctx.Repo.Repository.NumClosedPulls) +} + +func GetTotalPullsBadge(ctx *context_module.Context) { + getPullBadge(ctx, "", ctx.Repo.Repository.NumPulls) +} + +func GetStarsBadge(ctx *context_module.Context) { + redirectToBadge(ctx, "stars", fmt.Sprintf("%d", ctx.Repo.Repository.NumStars), "blue") +} + +func GetLatestReleaseBadge(ctx *context_module.Context) { + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + errorBadge(ctx, "release", "Not found") + return + } + ctx.ServerError("GetLatestReleaseByRepoID", err) + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + redirectToBadge(ctx, "release", release.TagName, "blue") +} diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go new file mode 100644 index 0000000..eea3d4d --- /dev/null +++ b/routers/web/repo/blame.go @@ -0,0 +1,298 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + gotemplate "html/template" + "net/http" + "net/url" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +type blameRow struct { + RowNumber int + Avatar gotemplate.HTML + RepoLink string + PartSha string + PreviousSha string + PreviousShaURL string + IsFirstCommit bool + CommitURL string + CommitMessage string + CommitSince gotemplate.HTML + Code gotemplate.HTML + EscapeStatus *charset.EscapeStatus +} + +// RefBlame render blame page +func RefBlame(ctx *context.Context) { + if ctx.Repo.TreePath == "" { + ctx.NotFound("No file specified", nil) + return + } + + paths := make([]string, 0, 5) + treeNames := strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return + } + blob := entry.Blob() + + ctx.Data["PageIsViewCode"] = true + ctx.Data["IsBlame"] = true + + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + ctx.Data["Paths"] = paths + ctx.Data["TreeNames"] = treeNames + + ctx.Data["FileSize"] = blob.Size() + ctx.Data["FileName"] = blob.Name() + + // Do not display a blame view if the size of the file is + // larger than what is configured as the maximum. + if blob.Size() >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + ctx.HTML(http.StatusOK, tplRepoHome) + return + } + + ctx.Data["NumLinesSet"] = true + ctx.Data["NumLines"], err = blob.GetBlobLineCount() + if err != nil { + ctx.ServerError("GetBlobLineCount", err) + return + } + + result, err := performBlame(ctx, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormBool("bypass-blame-ignore")) + if err != nil { + ctx.ServerError("performBlame", err) + return + } + + ctx.Data["UsesIgnoreRevs"] = result.UsesIgnoreRevs + ctx.Data["FaultyIgnoreRevsFile"] = result.FaultyIgnoreRevsFile + + commitNames := processBlameParts(ctx, result.Parts) + if ctx.Written() { + return + } + + renderBlame(ctx, result.Parts, commitNames) + + ctx.HTML(http.StatusOK, tplRepoHome) +} + +type blameResult struct { + Parts []*git.BlamePart + UsesIgnoreRevs bool + FaultyIgnoreRevsFile bool +} + +func performBlame(ctx *context.Context, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) { + repoPath := ctx.Repo.Repository.RepoPath() + objectFormat := ctx.Repo.GetObjectFormat() + + blameReader, err := git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, bypassBlameIgnore) + if err != nil { + return nil, err + } + + r := &blameResult{} + if err := fillBlameResult(blameReader, r); err != nil { + _ = blameReader.Close() + return nil, err + } + + err = blameReader.Close() + if err != nil { + if len(r.Parts) == 0 && r.UsesIgnoreRevs { + // try again without ignored revs + + blameReader, err = git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, true) + if err != nil { + return nil, err + } + + r := &blameResult{ + FaultyIgnoreRevsFile: true, + } + if err := fillBlameResult(blameReader, r); err != nil { + _ = blameReader.Close() + return nil, err + } + + return r, blameReader.Close() + } + return nil, err + } + return r, nil +} + +func fillBlameResult(br *git.BlameReader, r *blameResult) error { + r.UsesIgnoreRevs = br.UsesIgnoreRevs() + + previousHelper := make(map[string]*git.BlamePart) + + r.Parts = make([]*git.BlamePart, 0, 5) + for { + blamePart, err := br.NextPart() + if err != nil { + return fmt.Errorf("BlameReader.NextPart failed: %w", err) + } + if blamePart == nil { + break + } + + if prev, ok := previousHelper[blamePart.Sha]; ok { + if blamePart.PreviousSha == "" { + blamePart.PreviousSha = prev.PreviousSha + blamePart.PreviousPath = prev.PreviousPath + } + } else { + previousHelper[blamePart.Sha] = blamePart + } + + r.Parts = append(r.Parts, blamePart) + } + + return nil +} + +func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[string]*user_model.UserCommit { + // store commit data by SHA to look up avatar info etc + commitNames := make(map[string]*user_model.UserCommit) + // and as blameParts can reference the same commits multiple + // times, we cache the lookup work locally + commits := make([]*git.Commit, 0, len(blameParts)) + commitCache := map[string]*git.Commit{} + commitCache[ctx.Repo.Commit.ID.String()] = ctx.Repo.Commit + + for _, part := range blameParts { + sha := part.Sha + if _, ok := commitNames[sha]; ok { + continue + } + + // find the blamePart commit, to look up parent & email address for avatars + commit, ok := commitCache[sha] + var err error + if !ok { + commit, err = ctx.Repo.GitRepo.GetCommit(sha) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Repo.GitRepo.GetCommit", err) + } else { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + } + return nil + } + commitCache[sha] = commit + } + + commits = append(commits, commit) + } + + // populate commit email addresses to later look up avatars. + for _, c := range user_model.ValidateCommitsWithEmails(ctx, commits) { + commitNames[c.ID.String()] = c + } + + return commitNames +} + +func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) { + repoLink := ctx.Repo.RepoLink + + language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + if err != nil { + log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) + } + + lines := make([]string, 0) + rows := make([]*blameRow, 0) + escapeStatus := &charset.EscapeStatus{} + + var lexerName string + + avatarUtils := templates.NewAvatarUtils(ctx) + i := 0 + commitCnt := 0 + for _, part := range blameParts { + for index, line := range part.Lines { + i++ + lines = append(lines, line) + + br := &blameRow{ + RowNumber: i, + } + + commit := commitNames[part.Sha] + if index == 0 { + // Count commit number + commitCnt++ + + // User avatar image + commitSince := timeutil.TimeSinceUnix(timeutil.TimeStamp(commit.Author.When.Unix()), ctx.Locale) + + var avatar string + if commit.User != nil { + avatar = string(avatarUtils.Avatar(commit.User, 18)) + } else { + avatar = string(avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "tw-mr-2")) + } + + br.Avatar = gotemplate.HTML(avatar) + br.RepoLink = repoLink + br.PartSha = part.Sha + br.PreviousSha = part.PreviousSha + br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) + br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) + br.CommitMessage = commit.CommitMessage + br.CommitSince = commitSince + } + + if i != len(lines)-1 { + line += "\n" + } + fileName := fmt.Sprintf("%v", ctx.Data["FileName"]) + line, lexerNameForLine := highlight.Code(fileName, language, line) + + // set lexer name to the first detected lexer. this is certainly suboptimal and + // we should instead highlight the whole file at once + if lexerName == "" { + lexerName = lexerNameForLine + } + + br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale, charset.FileviewContext) + rows = append(rows, br) + escapeStatus = escapeStatus.Or(br.EscapeStatus) + } + } + + ctx.Data["EscapeStatus"] = escapeStatus + ctx.Data["BlameRows"] = rows + ctx.Data["CommitCnt"] = commitCnt + ctx.Data["LexerName"] = lexerName +} diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go new file mode 100644 index 0000000..4897a5f --- /dev/null +++ b/routers/web/repo/branch.go @@ -0,0 +1,262 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" + "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" +) + +// Branches render repository branch page +func Branches(ctx *context.Context) { + ctx.Data["Title"] = "Branches" + ctx.Data["IsRepoToolbarBranches"] = true + ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx) + ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode) + ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror + ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) || + (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)) + ctx.Data["PageIsViewCode"] = true + ctx.Data["PageIsBranches"] = true + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + pageSize := setting.Git.BranchesRangeSize + + kw := ctx.FormString("q") + + defaultBranch, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, optional.None[bool](), kw, page, pageSize) + if err != nil { + ctx.ServerError("LoadBranches", err) + return + } + + commitIDs := []string{defaultBranch.DBBranch.CommitID} + for _, branch := range branches { + commitIDs = append(commitIDs, branch.DBBranch.CommitID) + } + + commitStatuses, err := git_model.GetLatestCommitStatusForRepoCommitIDs(ctx, ctx.Repo.Repository.ID, commitIDs) + if err != nil { + ctx.ServerError("LoadBranches", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + + commitStatus := make(map[string]*git_model.CommitStatus) + for commitID, cs := range commitStatuses { + commitStatus[commitID] = git_model.CalcCommitStatus(cs) + } + + ctx.Data["Keyword"] = kw + ctx.Data["Branches"] = branches + ctx.Data["CommitStatus"] = commitStatus + ctx.Data["CommitStatuses"] = commitStatuses + ctx.Data["DefaultBranchBranch"] = defaultBranch + pager := context.NewPagination(int(branchesCount), pageSize, 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.FormString("name") + + if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil { + switch { + case git.IsErrBranchNotExist(err): + log.Debug("DeleteBranch: Can't delete non existing branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName)) + case errors.Is(err, repo_service.ErrBranchIsDefault): + log.Debug("DeleteBranch: Can't delete default branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.default_deletion_failed", branchName)) + case errors.Is(err, git_model.ErrBranchIsProtected): + log.Debug("DeleteBranch: Can't delete protected branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.protected_deletion_failed", branchName)) + default: + log.Error("DeleteBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName)) + } + + return + } + + ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", branchName)) +} + +// RestoreBranchPost responses for delete merged branch +func RestoreBranchPost(ctx *context.Context) { + defer redirect(ctx) + + branchID := ctx.FormInt64("branch_id") + branchName := ctx.FormString("name") + + deletedBranch, err := git_model.GetDeletedBranchByID(ctx, ctx.Repo.Repository.ID, branchID) + if err != nil { + log.Error("GetDeletedBranchByID: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName)) + return + } else if deletedBranch == nil { + log.Debug("RestoreBranch: Can't restore branch[%d] '%s', as it does not exist", branchID, branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName)) + return + } + + if err := git.Push(ctx, ctx.Repo.Repository.RepoPath(), git.PushOptions{ + Remote: ctx.Repo.Repository.RepoPath(), + Branch: fmt.Sprintf("%s:%s%s", deletedBranch.CommitID, git.BranchPrefix, deletedBranch.Name), + Env: repo_module.PushingEnvironment(ctx.Doer, ctx.Repo.Repository), + }); err != nil { + if strings.Contains(err.Error(), "already exists") { + log.Debug("RestoreBranch: Can't restore branch '%s', since one with same name already exist", deletedBranch.Name) + ctx.Flash.Error(ctx.Tr("repo.branch.already_exists", deletedBranch.Name)) + return + } + log.Error("RestoreBranch: CreateBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", deletedBranch.Name)) + return + } + + objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName) + + // Don't return error below this + if err := repo_service.PushUpdate( + &repo_module.PushUpdateOptions{ + RefFullName: git.RefNameFromBranch(deletedBranch.Name), + OldCommitID: objectFormat.EmptyObjectID().String(), + NewCommitID: deletedBranch.CommitID, + PusherID: ctx.Doer.ID, + PusherName: ctx.Doer.Name, + RepoUserName: ctx.Repo.Owner.Name, + RepoName: ctx.Repo.Repository.Name, + }); err != nil { + log.Error("RestoreBranch: Update: %v", err) + } + + ctx.Flash.Success(ctx.Tr("repo.branch.restore_success", deletedBranch.Name)) +} + +func redirect(ctx *context.Context) { + ctx.JSONRedirect(ctx.Repo.RepoLink + "/branches?page=" + url.QueryEscape(ctx.FormString("page"))) +} + +// CreateBranch creates new branch in repository +func CreateBranch(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewBranchForm) + if !ctx.Repo.CanCreateBranch() { + ctx.NotFound("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 { + target := ctx.Repo.CommitID + if ctx.Repo.IsViewBranch { + target = ctx.Repo.BranchName + } + err = release_service.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, target, form.NewBranchName, "") + } else if ctx.Repo.IsViewBranch { + err = repo_service.CreateNewBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.Repo.BranchName, form.NewBranchName) + } else { + err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.Repo.CommitID, form.NewBranchName) + } + if err != nil { + if models.IsErrProtectedTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + 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 git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) { + ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if git_model.IsErrBranchNameConflict(err) { + e := err.(git_model.ErrBranchNameConflict) + ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.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.RenderToHTML(tplAlertDetails, map[string]any{ + "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) + "/" + util.PathEscapeSegments(form.CurrentPath)) +} diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go new file mode 100644 index 0000000..90dae70 --- /dev/null +++ b/routers/web/repo/cherry_pick.go @@ -0,0 +1,192 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "errors" + "strings" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +var tplCherryPick base.TplName = "repo/editor/cherry_pick" + +// CherryPick handles cherrypick GETs +func CherryPick(ctx *context.Context) { + ctx.Data["SHA"] = ctx.Params(":sha") + cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(ctx.Params(":sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Missing Commit", err) + return + } + ctx.ServerError("GetCommit", err) + return + } + + if ctx.FormString("cherry-pick-type") == "revert" { + ctx.Data["CherryPickType"] = "revert" + ctx.Data["commit_summary"] = "revert " + ctx.Params(":sha") + ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message() + } else { + ctx.Data["CherryPickType"] = "cherry-pick" + splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2) + ctx.Data["commit_summary"] = splits[0] + ctx.Data["commit_message"] = splits[1] + } + + canCommit := renderCommitRights(ctx) + ctx.Data["TreePath"] = "" + + 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["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + ctx.HTML(200, tplCherryPick) +} + +// CherryPickPost handles cherrypick POSTs +func CherryPickPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CherryPickForm) + + sha := ctx.Params(":sha") + ctx.Data["SHA"] = sha + if form.Revert { + ctx.Data["CherryPickType"] = "revert" + } else { + ctx.Data["CherryPickType"] = "cherry-pick" + } + + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + 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["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + if ctx.HasError() { + ctx.HTML(200, tplCherryPick) + 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), tplCherryPick, &form) + return + } + + message := strings.TrimSpace(form.CommitSummary) + if message == "" { + if form.Revert { + message = ctx.Locale.TrString("repo.commit.revert-header", sha) + } else { + message = ctx.Locale.TrString("repo.commit.cherry-pick-header", sha) + } + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplCherryPick, &form) + if ctx.Written() { + return + } + + opts := &files.ApplyDiffPatchOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + Author: gitIdentity, + Committer: gitIdentity, + } + + // First lets try the simple plain read-tree -m approach + opts.Content = sha + if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, form.Revert, opts); err != nil { + if git_model.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(git_model.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } + // Drop through to the apply technique + + buf := &bytes.Buffer{} + if form.Revert { + if err := git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), sha, buf); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } + } else { + if err := git.GetRawDiff(ctx.Repo.GitRepo, sha, git.RawDiffType("patch"), buf); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } + } + + opts.Content = buf.String() + ctx.Data["FileContent"] = opts.Content + + if _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { + if git_model.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(git_model.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return + } + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { + 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)) + } +} diff --git a/routers/web/repo/code_frequency.go b/routers/web/repo/code_frequency.go new file mode 100644 index 0000000..c76f492 --- /dev/null +++ b/routers/web/repo/code_frequency.go @@ -0,0 +1,41 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" + contributors_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplCodeFrequency base.TplName = "repo/activity" +) + +// CodeFrequency renders the page to show repository code frequency +func CodeFrequency(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.code_frequency") + + ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsCodeFrequency"] = true + ctx.PageData["repoLink"] = ctx.Repo.RepoLink + + ctx.HTML(http.StatusOK, tplCodeFrequency) +} + +// CodeFrequencyData returns JSON of code frequency data +func CodeFrequencyData(ctx *context.Context) { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if errors.Is(err, contributors_service.ErrAwaitGeneration) { + ctx.Status(http.StatusAccepted) + return + } + ctx.ServerError("GetCodeFrequencyData", err) + } else { + ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) + } +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go new file mode 100644 index 0000000..0e5d1f0 --- /dev/null +++ b/routers/web/repo/commit.go @@ -0,0 +1,468 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "html/template" + "net/http" + "path" + "strings" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitgraph" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/gitdiff" + git_service "code.gitea.io/gitea/services/repository" +) + +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.FormInt("page") + if page <= 1 { + page = 1 + } + + pageSize := ctx.FormInt("limit") + if pageSize <= 0 { + pageSize = setting.Git.CommitsRangeSize + } + + // Both `git log branchName` and `git log commitId` work. + commits, err := ctx.Repo.Commit.CommitsByRange(page, pageSize, "") + if err != nil { + ctx.ServerError("CommitsByRange", err) + return + } + ctx.Data["Commits"] = processGitCommits(ctx, commits) + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commitsCount + + pager := context.NewPagination(int(commitsCount), pageSize, 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.FormTrim("mode")) + if mode != "monochrome" { + mode = "color" + } + ctx.Data["Mode"] = mode + hidePRRefs := ctx.FormBool("hide-pr-refs") + ctx.Data["HidePRRefs"] = hidePRRefs + branches := ctx.FormStrings("branch") + realBranches := make([]string, len(branches)) + copy(realBranches, branches) + for i, branch := range realBranches { + if strings.HasPrefix(branch, "--") { + realBranches[i] = git.BranchPrefix + branch + } + } + ctx.Data["SelectedBranches"] = realBranches + files := ctx.FormStrings("file") + + commitsCount, err := ctx.Repo.GetCommitsCount() + if err != nil { + ctx.ServerError("GetCommitsCount", err) + return + } + + graphCommitsCount, err := ctx.Repo.GetCommitGraphsCount(ctx, hidePRRefs, realBranches, files) + if err != nil { + log.Warn("GetCommitGraphsCount error for generate graph exclude prs: %t branches: %s in %-v, Will Ignore branches and try again. Underlying Error: %v", hidePRRefs, branches, ctx.Repo.Repository, err) + realBranches = []string{} + branches = []string{} + graphCommitsCount, err = ctx.Repo.GetCommitGraphsCount(ctx, hidePRRefs, realBranches, files) + if err != nil { + ctx.ServerError("GetCommitGraphsCount", err) + return + } + } + + page := ctx.FormInt("page") + + graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0, hidePRRefs, realBranches, files) + if err != nil { + ctx.ServerError("GetCommitGraph", err) + return + } + + if err := graph.LoadAndProcessCommits(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo); err != nil { + ctx.ServerError("LoadAndProcessCommits", err) + return + } + + ctx.Data["Graph"] = graph + + gitRefs, err := ctx.Repo.GitRepo.GetRefs() + if err != nil { + ctx.ServerError("GitRepo.GetRefs", err) + return + } + + ctx.Data["AllRefs"] = gitRefs + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commitsCount + + 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.FormBool("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 := ctx.FormTrim("q") + if len(query) == 0 { + ctx.Redirect(ctx.Repo.RepoLink + "/commits/" + ctx.Repo.BranchNameSubURL()) + return + } + + all := ctx.FormBool("all") + opts := git.NewSearchCommitsOptions(query, all) + commits, err := ctx.Repo.Commit.SearchCommits(opts) + if err != nil { + ctx.ServerError("SearchCommits", err) + return + } + ctx.Data["CommitCount"] = len(commits) + ctx.Data["Commits"] = processGitCommits(ctx, commits) + + ctx.Data["Keyword"] = query + if all { + ctx.Data["All"] = true + } + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.HTML(http.StatusOK, tplCommits) +} + +// FileHistory show a file's reversions +func FileHistory(ctx *context.Context) { + fileName := ctx.Repo.TreePath + if len(fileName) == 0 { + Commits(ctx) + return + } + + commitsCount, err := ctx.Repo.GitRepo.FileCommitsCount(ctx.Repo.RefName, fileName) + if err != nil { + ctx.ServerError("FileCommitsCount", err) + return + } else if commitsCount == 0 { + ctx.NotFound("FileCommitsCount", nil) + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange( + git.CommitsByFileAndRangeOptions{ + Revision: ctx.Repo.RefName, + File: fileName, + Page: page, + }) + if err != nil { + ctx.ServerError("CommitsByFileAndRange", err) + return + } + + if len(commits) == 0 { + ctx.NotFound("CommitsByFileAndRange", nil) + return + } + + oldestCommit := commits[len(commits)-1] + + renamedFiles, err := git.GetCommitFileRenames(ctx, ctx.Repo.GitRepo.Path, oldestCommit.ID.String()) + if err != nil { + ctx.ServerError("GetCommitFileRenames", err) + return + } + + for _, renames := range renamedFiles { + if renames[1] == fileName { + ctx.Data["OldFilename"] = renames[0] + ctx.Data["OldFilenameHistory"] = fmt.Sprintf("%s/commits/commit/%s/%s", ctx.Repo.RepoLink, oldestCommit.ID.String(), renames[0]) + break + } + } + + ctx.Data["Commits"] = processGitCommits(ctx, commits) + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["FileName"] = fileName + ctx.Data["CommitCount"] = commitsCount + + pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplCommits) +} + +func LoadBranchesAndTags(ctx *context.Context) { + response, err := git_service.LoadBranchesAndTags(ctx, ctx.Repo, ctx.Params("sha")) + if err == nil { + ctx.JSON(http.StatusOK, response) + return + } + ctx.NotFoundOrServerError(fmt.Sprintf("could not load branches and tags the commit %s belongs to", ctx.Params("sha")), git.IsErrNotExist, err) +} + +// Diff show different from current commit to previous commit +func Diff(ctx *context.Context) { + ctx.Data["PageIsDiff"] = true + + userName := ctx.Repo.Owner.Name + repoName := ctx.Repo.Repository.Name + commitID := ctx.Params(":sha") + var ( + gitRepo *git.Repository + err error + ) + + if ctx.Data["PageIsWiki"] != nil { + gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + return + } + defer gitRepo.Close() + } else { + gitRepo = ctx.Repo.GitRepo + } + + 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) != commit.ID.Type().FullLength() { + commitID = commit.ID.String() + } + + fileOnly := ctx.FormBool("file-only") + maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles + files := ctx.FormStrings("files") + if fileOnly && (len(files) == 2 || len(files) == 1) { + maxLines, maxFiles = -1, -1 + } + + diff, err := gitdiff.GetDiff(ctx, gitRepo, &gitdiff.DiffOptions{ + AfterCommitID: commitID, + SkipTo: ctx.FormString("skip-to"), + MaxLines: maxLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: maxFiles, + WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), + }, files...) + if err != nil { + ctx.NotFound("GetDiff", 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 + } + } + setCompareContext(ctx, parentCommit, commit, userName, repoName) + ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID) + ctx.Data["Commit"] = commit + ctx.Data["Diff"] = diff + + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + if !ctx.Repo.CanRead(unit_model.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, statuses) + } + + ctx.Data["CommitStatus"] = git_model.CalcCommitStatus(statuses) + ctx.Data["CommitStatuses"] = statuses + + verification := asymkey_model.ParseCommitWithSignature(ctx, commit) + ctx.Data["Verification"] = verification + ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit) + ctx.Data["Parents"] = parents + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { + return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) + }, nil); err != nil { + ctx.ServerError("CalculateTrustStatus", err) + return + } + + note := &git.Note{} + err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note) + if err == nil { + ctx.Data["NoteCommit"] = note.Commit + ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit) + ctx.Data["NoteRendered"], err = markup.RenderCommitMessage(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: path.Join("commit", util.PathEscapeSegments(commitID)), + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) + if err != nil { + ctx.ServerError("RenderCommitMessage", 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 gitRepo *git.Repository + if ctx.Data["PageIsWiki"] != nil { + wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("OpenRepository", err) + return + } + defer wikiRepo.Close() + gitRepo = wikiRepo + } else { + gitRepo = ctx.Repo.GitRepo + if gitRepo == nil { + ctx.ServerError("GitRepo not open", fmt.Errorf("no open git repo for '%s'", ctx.Repo.Repository.FullName())) + return + } + } + if err := git.GetRawDiff( + gitRepo, + ctx.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 + } +} + +func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) []*git_model.SignCommitWithStatuses { + commits := git_model.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository) + if !ctx.Repo.CanRead(unit_model.TypeActions) { + for _, commit := range commits { + if commit.Status == nil { + continue + } + commit.Status.HideActionsURL(ctx) + git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses) + } + } + return commits +} diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go new file mode 100644 index 0000000..38d6004 --- /dev/null +++ b/routers/web/repo/compare.go @@ -0,0 +1,972 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bufio" + gocontext "context" + "encoding/csv" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + csv_module "code.gitea.io/gitea/modules/csv" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/gitdiff" +) + +const ( + tplCompare base.TplName = "repo/diff/compare" + tplBlobExcerpt base.TplName = "repo/diff/blob_excerpt" + tplDiffBox base.TplName = "repo/diff/box" +) + +// setCompareContext sets context data. +func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner, headName string) { + ctx.Data["BeforeCommit"] = before + ctx.Data["HeadCommit"] = head + + ctx.Data["GetBlobByPathForCommit"] = func(commit *git.Commit, path string) *git.Blob { + if commit == nil { + return nil + } + + blob, err := commit.GetBlobByPath(path) + if err != nil { + return nil + } + return blob + } + + ctx.Data["GetSniffedTypeForBlob"] = func(blob *git.Blob) typesniffer.SniffedType { + st := typesniffer.SniffedType{} + + if blob == nil { + return st + } + + st, err := blob.GuessContentType() + if err != nil { + log.Error("GuessContentType failed: %v", err) + return st + } + return st + } + + setPathsCompareContext(ctx, before, head, headOwner, headName) + setImageCompareContext(ctx) + setCsvCompareContext(ctx) +} + +// SourceCommitURL creates a relative URL for a commit in the given repository +func SourceCommitURL(owner, name string, commit *git.Commit) string { + return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/src/commit/" + url.PathEscape(commit.ID.String()) +} + +// RawCommitURL creates a relative URL for the raw commit in the given repository +func RawCommitURL(owner, name string, commit *git.Commit) string { + return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/raw/commit/" + url.PathEscape(commit.ID.String()) +} + +// setPathsCompareContext sets context data for source and raw paths +func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOwner, headName string) { + ctx.Data["SourcePath"] = SourceCommitURL(headOwner, headName, head) + ctx.Data["RawPath"] = RawCommitURL(headOwner, headName, head) + if base != nil { + ctx.Data["BeforeSourcePath"] = SourceCommitURL(headOwner, headName, base) + ctx.Data["BeforeRawPath"] = RawCommitURL(headOwner, headName, base) + } +} + +// setImageCompareContext sets context data that is required by image compare template +func setImageCompareContext(ctx *context.Context) { + ctx.Data["IsSniffedTypeAnImage"] = func(st typesniffer.SniffedType) bool { + return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()) + } +} + +// setCsvCompareContext sets context data that is required by the CSV compare template +func setCsvCompareContext(ctx *context.Context) { + ctx.Data["IsCsvFile"] = func(diffFile *gitdiff.DiffFile) bool { + extension := strings.ToLower(filepath.Ext(diffFile.Name)) + return extension == ".csv" || extension == ".tsv" + } + + type CsvDiffResult struct { + Sections []*gitdiff.TableDiffSection + Error string + } + + ctx.Data["CreateCsvDiff"] = func(diffFile *gitdiff.DiffFile, baseBlob, headBlob *git.Blob) CsvDiffResult { + if diffFile == nil { + return CsvDiffResult{nil, ""} + } + + errTooLarge := errors.New(ctx.Locale.TrString("repo.error.csv.too_large")) + + csvReaderFromCommit := func(ctx *markup.RenderContext, blob *git.Blob) (*csv.Reader, io.Closer, error) { + if blob == nil { + // It's ok for blob to be nil (file added or deleted) + return nil, nil, nil + } + + if setting.UI.CSV.MaxFileSize != 0 && setting.UI.CSV.MaxFileSize < blob.Size() { + return nil, nil, errTooLarge + } + + reader, err := blob.DataAsync() + if err != nil { + return nil, nil, err + } + + csvReader, err := csv_module.CreateReaderAndDetermineDelimiter(ctx, charset.ToUTF8WithFallbackReader(reader, charset.ConvertOpts{})) + return csvReader, reader, err + } + + baseReader, baseBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, RelativePath: diffFile.OldName}, baseBlob) + if baseBlobCloser != nil { + defer baseBlobCloser.Close() + } + if err != nil { + if err == errTooLarge { + return CsvDiffResult{nil, err.Error()} + } + log.Error("error whilst creating csv.Reader from file %s in base commit %s in %s: %v", diffFile.Name, baseBlob.ID.String(), ctx.Repo.Repository.Name, err) + return CsvDiffResult{nil, "unable to load file"} + } + + headReader, headBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, RelativePath: diffFile.Name}, headBlob) + if headBlobCloser != nil { + defer headBlobCloser.Close() + } + if err != nil { + if err == errTooLarge { + return CsvDiffResult{nil, err.Error()} + } + log.Error("error whilst creating csv.Reader from file %s in head commit %s in %s: %v", diffFile.Name, headBlob.ID.String(), ctx.Repo.Repository.Name, err) + return CsvDiffResult{nil, "unable to load file"} + } + + sections, err := gitdiff.CreateCsvDiff(diffFile, baseReader, headReader) + if err != nil { + errMessage, err := csv_module.FormatError(err, ctx.Locale) + if err != nil { + log.Error("CreateCsvDiff FormatError failed: %v", err) + return CsvDiffResult{nil, "unknown csv diff error"} + } + return CsvDiffResult{nil, errMessage} + } + return CsvDiffResult{sections, ""} + } +} + +// ParseCompareInfo parse compare info between two commit for preparing comparing references +func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { + baseRepo := ctx.Repo.Repository + ci := &common.CompareInfo{} + + fileOnly := ctx.FormBool("file-only") + + // 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} + // 4. /{:baseOwner}/{:baseRepoName}/compare/{:headBranch} + // 5. /{:baseOwner}/{:baseRepoName}/compare/{:headOwner}:{:headBranch} + // 6. /{:baseOwner}/{:baseRepoName}/compare/{: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 ( + isSameRepo bool + infoPath string + err error + ) + + infoPath = ctx.Params("*") + var infos []string + if infoPath == "" { + infos = []string{baseRepo.DefaultBranch, baseRepo.DefaultBranch} + } else { + infos = strings.SplitN(infoPath, "...", 2) + if len(infos) != 2 { + if infos = strings.SplitN(infoPath, "..", 2); len(infos) == 2 { + ci.DirectComparison = true + ctx.Data["PageIsComparePull"] = false + } else { + infos = []string{baseRepo.DefaultBranch, infoPath} + } + } + } + + ctx.Data["BaseName"] = baseRepo.OwnerName + ci.BaseBranch = infos[0] + ctx.Data["BaseBranch"] = ci.BaseBranch + + // If there is no head repository, it means compare between same repository. + headInfos := strings.Split(infos[1], ":") + if len(headInfos) == 1 { + isSameRepo = true + ci.HeadUser = ctx.Repo.Owner + ci.HeadBranch = headInfos[0] + } else if len(headInfos) == 2 { + headInfosSplit := strings.Split(headInfos[0], "/") + if len(headInfosSplit) == 1 { + ci.HeadUser, err = user_model.GetUserByName(ctx, headInfos[0]) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", nil) + } else { + ctx.ServerError("GetUserByName", err) + } + return nil + } + ci.HeadBranch = headInfos[1] + isSameRepo = ci.HeadUser.ID == ctx.Repo.Owner.ID + if isSameRepo { + ci.HeadRepo = baseRepo + } + } else { + ci.HeadRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, headInfosSplit[0], headInfosSplit[1]) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByOwnerAndName", nil) + } else { + ctx.ServerError("GetRepositoryByOwnerAndName", err) + } + return nil + } + if err := ci.HeadRepo.LoadOwner(ctx); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", nil) + } else { + ctx.ServerError("GetUserByName", err) + } + return nil + } + ci.HeadBranch = headInfos[1] + ci.HeadUser = ci.HeadRepo.Owner + isSameRepo = ci.HeadRepo.ID == ctx.Repo.Repository.ID + } + } else { + ctx.NotFound("CompareAndPullRequest", nil) + return nil + } + ctx.Data["HeadUser"] = ci.HeadUser + ctx.Data["HeadBranch"] = ci.HeadBranch + ctx.Repo.PullRequest.SameRepo = isSameRepo + + // Check if base branch is valid. + baseIsCommit := ctx.Repo.GitRepo.IsCommitExist(ci.BaseBranch) + baseIsBranch := ctx.Repo.GitRepo.IsBranchExist(ci.BaseBranch) + baseIsTag := ctx.Repo.GitRepo.IsTagExist(ci.BaseBranch) + + if !baseIsCommit && !baseIsBranch && !baseIsTag { + // Check if baseBranch is short sha commit hash + if baseCommit, _ := ctx.Repo.GitRepo.GetCommit(ci.BaseBranch); baseCommit != nil { + ci.BaseBranch = baseCommit.ID.String() + ctx.Data["BaseBranch"] = ci.BaseBranch + baseIsCommit = true + } else if ci.BaseBranch == ctx.Repo.GetObjectFormat().EmptyObjectID().String() { + if isSameRepo { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ci.HeadBranch)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ci.HeadRepo.FullName()) + ":" + util.PathEscapeSegments(ci.HeadBranch)) + } + return nil + } else { + ctx.NotFound("IsRefExist", nil) + return 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 *repo_model.Repository + if baseRepo.IsFork { + err = baseRepo.GetBaseRepo(ctx) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + ctx.ServerError("Unable to find root repo", err) + return 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 *repo_model.Repository + if ctx.Doer != nil && baseRepo.OwnerID != ctx.Doer.ID { + repo := repo_model.GetForkedRepo(ctx, ctx.Doer.ID, baseRepo.ID) + if repo != nil { + ownForkRepo = repo + ctx.Data["OwnForkRepo"] = ownForkRepo + } + } + + has := ci.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 == ci.HeadUser.ID { + ci.HeadRepo = rootRepo + has = true + } + + // 4. If the ctx.Doer has their own fork of the baseRepo and the headUser is the ctx.Doer + // set the headRepo to the ownFork + if !has && ownForkRepo != nil && ownForkRepo.OwnerID == ci.HeadUser.ID { + ci.HeadRepo = ownForkRepo + has = true + } + + // 5. If the headOwner has a fork of the baseRepo - use that + if !has { + ci.HeadRepo = repo_model.GetForkedRepo(ctx, ci.HeadUser.ID, baseRepo.ID) + has = ci.HeadRepo != nil + } + + // 6. If the baseRepo is a fork and the headUser has a fork of that use that + if !has && baseRepo.IsFork { + ci.HeadRepo = repo_model.GetForkedRepo(ctx, ci.HeadUser.ID, baseRepo.ForkID) + has = ci.HeadRepo != nil + } + + // 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 + if isSameRepo { + ci.HeadRepo = ctx.Repo.Repository + ci.HeadGitRepo = ctx.Repo.GitRepo + } else if has { + ci.HeadGitRepo, err = gitrepo.OpenRepository(ctx, ci.HeadRepo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer ci.HeadGitRepo.Close() + } else { + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + + ctx.Data["HeadRepo"] = ci.HeadRepo + ctx.Data["BaseCompareRepo"] = ctx.Repo.Repository + + // Now we need to assert that the ctx.Doer has permission to read + // the baseRepo's code and pulls + // (NOT headRepo's) + permBase, err := access_model.GetUserRepoPermission(ctx, baseRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + if !permBase.CanRead(unit.TypeCode) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in baseRepo has Permissions: %-+v", + ctx.Doer, + baseRepo, + permBase) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + + // If we're not merging from the same repo: + if !isSameRepo { + // Assert ctx.Doer has permission to read headRepo's codes + permHead, err := access_model.GetUserRepoPermission(ctx, ci.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + if !permHead.CanRead(unit.TypeCode) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in headRepo has Permissions: %-+v", + ctx.Doer, + ci.HeadRepo, + permHead) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + ctx.Data["CanWriteToHeadRepo"] = permHead.CanWrite(unit.TypeCode) + } + + // 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 != ci.HeadRepo.ID && + rootRepo.ID != baseRepo.ID { + canRead := access_model.CheckRepoUnitUser(ctx, rootRepo, ctx.Doer, unit.TypeCode) + if canRead { + ctx.Data["RootRepo"] = rootRepo + if !fileOnly { + branches, tags, err := getBranchesAndTagsForRepo(ctx, rootRepo) + if err != nil { + ctx.ServerError("GetBranchesForRepo", err) + return nil + } + + 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 != ci.HeadRepo.ID && + ownForkRepo.ID != baseRepo.ID && + (rootRepo == nil || ownForkRepo.ID != rootRepo.ID) { + canRead := access_model.CheckRepoUnitUser(ctx, ownForkRepo, ctx.Doer, unit.TypeCode) + if canRead { + ctx.Data["OwnForkRepo"] = ownForkRepo + if !fileOnly { + branches, tags, err := getBranchesAndTagsForRepo(ctx, ownForkRepo) + if err != nil { + ctx.ServerError("GetBranchesForRepo", err) + return nil + } + ctx.Data["OwnForkRepoBranches"] = branches + ctx.Data["OwnForkRepoTags"] = tags + } + } + } + + // Check if head branch is valid. + headIsCommit := ci.HeadGitRepo.IsCommitExist(ci.HeadBranch) + headIsBranch := ci.HeadGitRepo.IsBranchExist(ci.HeadBranch) + headIsTag := ci.HeadGitRepo.IsTagExist(ci.HeadBranch) + if !headIsCommit && !headIsBranch && !headIsTag { + // Check if headBranch is short sha commit hash + if headCommit, _ := ci.HeadGitRepo.GetCommit(ci.HeadBranch); headCommit != nil { + ci.HeadBranch = headCommit.ID.String() + ctx.Data["HeadBranch"] = ci.HeadBranch + headIsCommit = true + } else { + ctx.NotFound("IsRefExist", nil) + return 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.Doer, + baseRepo, + permBase) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + + baseBranchRef := ci.BaseBranch + if baseIsBranch { + baseBranchRef = git.BranchPrefix + ci.BaseBranch + } else if baseIsTag { + baseBranchRef = git.TagPrefix + ci.BaseBranch + } + headBranchRef := ci.HeadBranch + if headIsBranch { + headBranchRef = git.BranchPrefix + ci.HeadBranch + } else if headIsTag { + headBranchRef = git.TagPrefix + ci.HeadBranch + } + + ci.CompareInfo, err = ci.HeadGitRepo.GetCompareInfo(baseRepo.RepoPath(), baseBranchRef, headBranchRef, ci.DirectComparison, fileOnly) + if err != nil { + ctx.ServerError("GetCompareInfo", err) + return nil + } + if ci.DirectComparison { + ctx.Data["BeforeCommitID"] = ci.CompareInfo.BaseCommitID + } else { + ctx.Data["BeforeCommitID"] = ci.CompareInfo.MergeBase + } + + return ci +} + +// PrepareCompareDiff renders compare diff page +func PrepareCompareDiff( + ctx *context.Context, + ci *common.CompareInfo, + whitespaceBehavior git.TrustedCmdArgs, +) bool { + var ( + repo = ctx.Repo.Repository + err error + title string + ) + + // Get diff information. + ctx.Data["CommitRepoLink"] = ci.HeadRepo.Link() + + headCommitID := ci.CompareInfo.HeadCommitID + + ctx.Data["AfterCommitID"] = headCommitID + + if (headCommitID == ci.CompareInfo.MergeBase && !ci.DirectComparison) || + headCommitID == ci.CompareInfo.BaseCommitID { + ctx.Data["IsNothingToCompare"] = true + if unit, err := repo.GetUnit(ctx, unit.TypePullRequests); err == nil { + config := unit.PullRequestsConfig() + + if !config.AutodetectManualMerge { + allowEmptyPr := !(ci.BaseBranch == ci.HeadBranch && ctx.Repo.Repository.Name == ci.HeadRepo.Name) + ctx.Data["AllowEmptyPr"] = allowEmptyPr + + return !allowEmptyPr + } + + ctx.Data["AllowEmptyPr"] = false + } + return true + } + + beforeCommitID := ci.CompareInfo.MergeBase + if ci.DirectComparison { + beforeCommitID = ci.CompareInfo.BaseCommitID + } + + maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles + files := ctx.FormStrings("files") + if len(files) == 2 || len(files) == 1 { + maxLines, maxFiles = -1, -1 + } + + diff, err := gitdiff.GetDiff(ctx, ci.HeadGitRepo, + &gitdiff.DiffOptions{ + BeforeCommitID: beforeCommitID, + AfterCommitID: headCommitID, + SkipTo: ctx.FormString("skip-to"), + MaxLines: maxLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: maxFiles, + WhitespaceBehavior: whitespaceBehavior, + DirectComparison: ci.DirectComparison, + }, ctx.FormStrings("files")...) + if err != nil { + ctx.ServerError("GetDiffRangeWithWhitespaceBehavior", err) + return false + } + ctx.Data["Diff"] = diff + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return false + } + + baseGitRepo := ctx.Repo.GitRepo + + beforeCommit, err := baseGitRepo.GetCommit(beforeCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return false + } + + commits := processGitCommits(ctx, ci.CompareInfo.Commits) + ctx.Data["Commits"] = commits + ctx.Data["CommitCount"] = len(commits) + + if len(commits) == 1 { + c := commits[0] + 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 = ci.HeadBranch + } + if len(title) > 255 { + var trailer string + title, trailer = util.SplitStringAtByteN(title, 255) + if len(trailer) > 0 { + if ctx.Data["content"] != nil { + ctx.Data["content"] = fmt.Sprintf("%s\n\n%s", trailer, ctx.Data["content"]) + } else { + ctx.Data["content"] = trailer + "\n" + } + } + } + + ctx.Data["title"] = title + ctx.Data["Username"] = ci.HeadUser.Name + ctx.Data["Reponame"] = ci.HeadRepo.Name + + setCompareContext(ctx, beforeCommit, headCommit, ci.HeadUser.Name, ci.HeadRepo.Name) + + return false +} + +func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repository) (branches, tags []string, err error) { + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return nil, nil, err + } + defer gitRepo.Close() + + branches, err = git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ + RepoID: repo.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), + }) + if err != nil { + return nil, nil, err + } + tags, err = gitRepo.GetTags(0, 0) + if err != nil { + return nil, nil, err + } + return branches, tags, nil +} + +// CompareDiff show different from one commit to another commit +func CompareDiff(ctx *context.Context) { + ci := ParseCompareInfo(ctx) + defer func() { + if ci != nil && ci.HeadGitRepo != nil { + ci.HeadGitRepo.Close() + } + }() + if ctx.Written() { + return + } + + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + ctx.Data["DirectComparison"] = ci.DirectComparison + ctx.Data["OtherCompareSeparator"] = ".." + ctx.Data["CompareSeparator"] = "..." + if ci.DirectComparison { + ctx.Data["CompareSeparator"] = ".." + ctx.Data["OtherCompareSeparator"] = "..." + } + + nothingToCompare := PrepareCompareDiff(ctx, ci, + gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + if ctx.Written() { + return + } + + baseTags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = baseTags + + fileOnly := ctx.FormBool("file-only") + if fileOnly { + ctx.HTML(http.StatusOK, tplDiffBox) + return + } + + headBranches, err := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ + RepoID: ci.HeadRepo.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), + }) + if err != nil { + ctx.ServerError("GetBranches", err) + return + } + ctx.Data["HeadBranches"] = headBranches + + // For compare repo branches + PrepareBranchList(ctx) + if ctx.Written() { + return + } + + headTags, err := repo_model.GetTagNamesByRepoID(ctx, ci.HeadRepo.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["HeadTags"] = headTags + + if ctx.Data["PageIsComparePull"] == true { + pr, err := issues_model.GetUnmergedPullRequest(ctx, ci.HeadRepo.ID, ctx.Repo.Repository.ID, ci.HeadBranch, ci.BaseBranch, issues_model.PullRequestFlowGithub) + if err != nil { + if !issues_model.IsErrPullRequestNotExist(err) { + ctx.ServerError("GetUnmergedPullRequest", err) + return + } + } else { + ctx.Data["HasPullRequest"] = true + if err := pr.LoadIssue(ctx); err != nil { + ctx.ServerError("LoadIssue", err) + return + } + ctx.Data["PullRequest"] = pr + 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) + + separator := "..." + if ci.DirectComparison { + separator = ".." + } + ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + separator + base.ShortSha(afterCommitID) + + ctx.Data["IsDiffCompare"] = true + _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) + + if len(templateErrs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) + } + + if content, ok := ctx.Data["content"].(string); ok && content != "" { + // If a template content is set, prepend the "content". In this case that's only + // applicable if you have one commit to compare and that commit has a message. + // In that case the commit message will be prepend to the template body. + if templateContent, ok := ctx.Data[pullRequestTemplateKey].(string); ok && templateContent != "" { + // Reuse the same key as that's prioritized over the "content" key. + // Add two new lines between the content to ensure there's always at least + // one empty line between them. + ctx.Data[pullRequestTemplateKey] = content + "\n\n" + templateContent + } + + // When using form fields, also add content to field with id "body". + if fields, ok := ctx.Data["Fields"].([]*api.IssueFormField); ok { + for _, field := range fields { + if field.ID == "body" { + if fieldValue, ok := field.Attributes["value"].(string); ok && fieldValue != "" { + field.Attributes["value"] = content + "\n\n" + fieldValue + } else { + field.Attributes["value"] = content + } + } + } + } + } + + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypePullRequests) + + if unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypePullRequests); err == nil { + config := unit.PullRequestsConfig() + ctx.Data["AllowMaintainerEdit"] = config.DefaultAllowMaintainerEdit + } else { + ctx.Data["AllowMaintainerEdit"] = false + } + + ctx.HTML(http.StatusOK, tplCompare) +} + +// ExcerptBlob render blob excerpt contents +func ExcerptBlob(ctx *context.Context) { + commitID := ctx.Params("sha") + lastLeft := ctx.FormInt("last_left") + lastRight := ctx.FormInt("last_right") + idxLeft := ctx.FormInt("left") + idxRight := ctx.FormInt("right") + leftHunkSize := ctx.FormInt("left_hunk_size") + rightHunkSize := ctx.FormInt("right_hunk_size") + anchor := ctx.FormString("anchor") + direction := ctx.FormString("direction") + filePath := ctx.FormString("path") + gitRepo := ctx.Repo.GitRepo + if ctx.FormBool("wiki") { + var err error + gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("OpenRepository", err) + return + } + defer gitRepo.Close() + } + 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 { + offset := -1 + if direction == "down" { + offset = 0 + } + section.Lines, err = getExcerptLines(commit, filePath, lastLeft, lastRight, idxRight-lastRight+offset) + 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["FileNameHash"] = git.HashFilePathForWebUI(filePath) + ctx.Data["AfterCommitID"] = commitID + ctx.Data["Anchor"] = anchor + ctx.HTML(http.StatusOK, tplBlobExcerpt) +} + +func getExcerptLines(commit *git.Commit, filePath string, idxLeft, idxRight, 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) + } + if err = scanner.Err(); err != nil { + return nil, fmt.Errorf("getExcerptLines scan: %w", err) + } + return diffLines, nil +} diff --git a/routers/web/repo/contributors.go b/routers/web/repo/contributors.go new file mode 100644 index 0000000..762fbf9 --- /dev/null +++ b/routers/web/repo/contributors.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" + contributors_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplContributors base.TplName = "repo/activity" +) + +// Contributors render the page to show repository contributors graph +func Contributors(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.contributors") + ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsContributors"] = true + ctx.HTML(http.StatusOK, tplContributors) +} + +// ContributorsData renders JSON of contributors along with their weekly commit statistics +func ContributorsData(ctx *context.Context) { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if errors.Is(err, contributors_service.ErrAwaitGeneration) { + ctx.Status(http.StatusAccepted) + return + } + ctx.ServerError("GetContributorStats", err) + } else { + ctx.JSON(http.StatusOK, contributorStats) + } +} diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go new file mode 100644 index 0000000..c4a8bae --- /dev/null +++ b/routers/web/repo/download.go @@ -0,0 +1,170 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "path" + "time" + + git_model "code.gitea.io/gitea/models/git" + "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/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" +) + +// ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary +func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Time) error { + if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { + 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, _ := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid) + if meta == nil { + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + closed = true + return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) + } + if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) { + return nil + } + + if setting.LFS.Storage.MinioConfig.ServeDirect { + // If we have a signed url (S3, object storage), redirect to this directly. + u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name()) + if u != nil && err == nil { + ctx.Redirect(u.String()) + 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) + } + }() + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc) + return nil + } + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + closed = true + + return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) +} + +func getBlobForEntry(ctx *context.Context) (blob *git.Blob, lastModified *time.Time) { + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetTreeEntryByPath", err) + } else { + ctx.ServerError("GetTreeEntryByPath", err) + } + return nil, nil + } + + if entry.IsDir() || entry.IsSubModule() { + ctx.NotFound("getBlobForEntry", nil) + return nil, nil + } + + info, _, err := git.Entries([]*git.TreeEntry{entry}).GetCommitsInfo(ctx, ctx.Repo.Commit, path.Dir("/" + ctx.Repo.TreePath)[1:]) + if err != nil { + ctx.ServerError("GetCommitsInfo", err) + return nil, nil + } + + if len(info) == 1 { + // Not Modified + lastModified = &info[0].Commit.Committer.When + } + blob = entry.Blob() + + return blob, lastModified +} + +// SingleDownload download a file by repos path +func SingleDownload(ctx *context.Context) { + blob, lastModified := getBlobForEntry(ctx) + if blob == nil { + return + } + + if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { + ctx.ServerError("ServeBlob", err) + } +} + +// SingleDownloadOrLFS download a file by repos path redirecting to LFS if necessary +func SingleDownloadOrLFS(ctx *context.Context) { + blob, lastModified := getBlobForEntry(ctx) + if blob == nil { + return + } + + if err := ServeBlobOrLFS(ctx, blob, lastModified); err != nil { + ctx.ServerError("ServeBlobOrLFS", err) + } +} + +// DownloadByID download a file by sha1 ID +func DownloadByID(ctx *context.Context) { + 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.Base, ctx.Repo.TreePath, blob, nil); err != nil { + ctx.ServerError("ServeBlob", err) + } +} + +// DownloadByIDOrLFS download a file by sha1 ID taking account of LFS +func DownloadByIDOrLFS(ctx *context.Context) { + 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, nil); 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 0000000..00c3d88 --- /dev/null +++ b/routers/web/repo/editor.go @@ -0,0 +1,962 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "io" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "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/routers/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/forms" + files_service "code.gitea.io/gitea/services/repository/files" +) + +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 canCreateBasePullRequest(ctx *context.Context) bool { + baseRepo := ctx.Repo.Repository.BaseRepo + return baseRepo != nil && baseRepo.UnitEnabled(ctx, unit.TypePullRequests) +} + +func renderCommitRights(ctx *context.Context) bool { + canCommitToBranch, err := ctx.Repo.CanCommitToBranch(ctx, ctx.Doer) + if err != nil { + log.Error("CanCommitToBranch: %v", err) + } + ctx.Data["CanCommitToBranch"] = canCommitToBranch + ctx.Data["CanCreatePullRequest"] = ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) || canCreateBasePullRequest(ctx) + + return canCommitToBranch.CanCommitToBranch +} + +// redirectForCommitChoice redirects after committing the edit to a branch +func redirectForCommitChoice(ctx *context.Context, commitChoice, newBranchName, treePath string) { + if commitChoice == frmCommitChoiceNewBranch { + // Redirect to a pull request when possible + redirectToPullRequest := false + repo := ctx.Repo.Repository + baseBranch := ctx.Repo.BranchName + headBranch := newBranchName + if repo.UnitEnabled(ctx, unit.TypePullRequests) { + redirectToPullRequest = true + } else if canCreateBasePullRequest(ctx) { + redirectToPullRequest = true + baseBranch = repo.BaseRepo.DefaultBranch + headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch + repo = repo.BaseRepo + } + + if redirectToPullRequest { + ctx.Redirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch)) + return + } + } + + // Redirect to viewing file or folder + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(newBranchName) + "/" + util.PathEscapeSegments(treePath)) +} + +// getParentTreeFields returns list of parent tree names and corresponding tree paths +// based on given tree path. +func getParentTreeFields(treePath string) (treeNames, 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 +} + +// getSelectableEmailAddresses returns which emails can be used by the user as +// email for a Git commiter. +func getSelectableEmailAddresses(ctx *context.Context) ([]*user_model.ActivatedEmailAddress, error) { + // Retrieve emails that the user could use for commiter identity. + commitEmails, err := user_model.GetActivatedEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + return nil, fmt.Errorf("GetActivatedEmailAddresses: %w", err) + } + + // Allow for the placeholder mail to be used. Use -1 as ID to identify + // this entry to be the placerholder mail of the user. + placeholderMail := &user_model.ActivatedEmailAddress{ID: -1, Email: ctx.Doer.GetPlaceholderEmail()} + if ctx.Doer.KeepEmailPrivate { + commitEmails = append([]*user_model.ActivatedEmailAddress{placeholderMail}, commitEmails...) + } else { + commitEmails = append(commitEmails, placeholderMail) + } + + return commitEmails, nil +} + +// CommonEditorData sets common context data that is used by the editor. +func CommonEditorData(ctx *context.Context) { + // Set context for selectable email addresses. + commitEmails, err := getSelectableEmailAddresses(ctx) + if err != nil { + ctx.ServerError("getSelectableEmailAddresses", err) + return + } + ctx.Data["CommitMails"] = commitEmails + ctx.Data["DefaultCommitMail"] = ctx.Doer.GetEmail() +} + +func editFile(ctx *context.Context, isNewFile bool) { + ctx.Data["PageIsEdit"] = true + ctx.Data["IsNewFile"] = isNewFile + 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 + } + + // Check if the filename (and additional path) is specified in the querystring + // (filename is a misnomer, but kept for compatibility with GitHub) + filePath, fileName := path.Split(ctx.Req.URL.Query().Get("filename")) + filePath = strings.Trim(filePath, "/") + treeNames, treePaths := getParentTreeFields(path.Join(ctx.Repo.TreePath, filePath)) + + if !isNewFile { + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", 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, _ := util.ReadAtMost(dataRc, 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, _ := io.ReadAll(dataRc) + + buf = append(buf, d...) + if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { + log.Error("ToUTF8: %v", err) + ctx.Data["FileContent"] = string(buf) + } else { + ctx.Data["FileContent"] = content + } + } else { + // Append filename from query, or empty string to allow user name the new file. + treeNames = append(treeNames, fileName) + } + + 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["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["EditorconfigJson"] = 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 { + 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["TreePath"] = form.TreePath + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(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["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["EditorconfigJson"] = 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.Locale.TrString("repo.editor.add", form.TreePath) + } else { + message = ctx.Locale.TrString("repo.editor.update", form.TreePath) + } + } + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + operation := "update" + if isNewFile { + operation = "create" + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplEditFile, form) + if ctx.Written() { + return + } + + if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + Files: []*files_service.ChangeRepoFile{ + { + Operation: operation, + FromTreePath: ctx.Repo.TreePath, + TreePath: form.TreePath, + ContentReader: strings.NewReader(strings.ReplaceAll(form.Content, "\r", "")), + }, + }, + Signoff: form.Signoff, + Author: gitIdentity, + Committer: gitIdentity, + }); err != nil { + // This is where we handle all the errors thrown by files_service.ChangeRepoFiles + if git.IsErrNotExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form) + } else if git_model.IsErrLFSFileLocked(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(git_model.ErrLFSFileLocked).Path, err.(git_model.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 git_model.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + ctx.Data["Err_NewBranchName"] = true + if branchErr, ok := err.(git_model.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.commit_id_not_matching"), tplEditFile, &form) + } else if git.IsErrPushOutOfDate(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_out_of_date"), 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.RenderToHTML(tplAlertDetails, map[string]any{ + "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.RenderToHTML(tplAlertDetails, map[string]any{ + "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 ctx.Repo.Repository.IsEmpty { + if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { + _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") + } + } + + redirectForCommitChoice(ctx, form.CommitChoice, branchName, 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 := files_service.GetDiffPreview(ctx, 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(http.StatusOK, ctx.Locale.TrString("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.Locale.TrString("repo.editor.delete", ctx.Repo.TreePath) + } + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplDeleteFile, &form) + if ctx.Written() { + return + } + + if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: ctx.Repo.TreePath, + }, + }, + Message: message, + Signoff: form.Signoff, + Author: gitIdentity, + Committer: gitIdentity, + }); 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 git_model.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + if branchErr, ok := err.(git_model.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/"+util.PathEscapeSegments(form.LastCommit)+"..."+util.PathEscapeSegments(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.RenderToHTML(tplAlertDetails, map[string]any{ + "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) + } + return + } + + ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", ctx.Repo.TreePath)) + 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 + } + } + + redirectForCommitChoice(ctx, form.CommitChoice, branchName, treePath) +} + +// UploadFile render upload file page +func UploadFile(ctx *context.Context) { + ctx.Data["PageIsUpload"] = 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 + 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/" + util.PathEscapeSegments(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 := ctx.Repo.GitRepo.GetBranch(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 + } + + if !ctx.Repo.Repository.IsEmpty { + 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) { + break // Means there is no item with that name, so we're good + } + ctx.ServerError("Repo.Commit.GetTreeEntryByPath", err) + return + } + + // User can only upload files to a directory, the directory name shouldn't be an existing file. + 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 { + dir := form.TreePath + if dir == "" { + dir = "/" + } + message = ctx.Locale.TrString("repo.editor.upload_files_to_dir", dir) + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplUploadFile, &form) + if ctx.Written() { + return + } + + if err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ + LastCommitID: ctx.Repo.CommitID, + OldBranch: oldBranchName, + NewBranch: branchName, + TreePath: form.TreePath, + Message: message, + Files: form.Files, + Signoff: form.Signoff, + Author: gitIdentity, + Committer: gitIdentity, + }); err != nil { + if git_model.IsErrLFSFileLocked(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(git_model.ErrLFSFileLocked).Path, err.(git_model.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 git_model.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + ctx.Data["Err_NewBranchName"] = true + branchErr := err.(git_model.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/"+util.PathEscapeSegments(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.RenderToHTML(tplAlertDetails, map[string]any{ + "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 ctx.Repo.Repository.IsEmpty { + if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { + _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") + } + } + + redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) +} + +func cleanUploadFileName(name string) string { + // Rebase the filename + name = util.PathJoinRel(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, _ := util.ReadAtMost(file, 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 := repo_model.NewUpload(ctx, 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(http.StatusNoContent) + return + } + + if err := repo_model.DeleteUploadByUUID(ctx, form.File); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteUploadByUUID: %v", err)) + return + } + + log.Trace("Upload file removed: %s", form.File) + ctx.Status(http.StatusNoContent) +} + +// 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.Doer.LowerName + "-patch-" + for i := 1; i <= 1000; i++ { + branchName := fmt.Sprintf("%s%d", prefix, i) + if _, err := ctx.Repo.GitRepo.GetBranch(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 +} + +// getGitIdentity returns the Git identity that should be used for an Git +// operation, that takes into account an user's specified email. +func getGitIdentity(ctx *context.Context, commitMailID int64, tpl base.TplName, form any) *files_service.IdentityOptions { + gitIdentity := &files_service.IdentityOptions{ + Name: ctx.Doer.Name, + } + + // -1 is defined as placeholder email. + if commitMailID == -1 { + gitIdentity.Email = ctx.Doer.GetPlaceholderEmail() + } else { + // Check if the given email is activated. + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, commitMailID) + if err != nil { + ctx.ServerError("GetEmailAddressByID", err) + return nil + } + + if email == nil || !email.IsActivated { + ctx.Data["Err_CommitMailID"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_mail"), tpl, form) + return nil + } + + gitIdentity.Email = email.Email + } + + return gitIdentity +} diff --git a/routers/web/repo/editor_test.go b/routers/web/repo/editor_test.go new file mode 100644 index 0000000..4d565b5 --- /dev/null +++ b/routers/web/repo/editor_test.go @@ -0,0 +1,73 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestCleanUploadName(t *testing.T) { + unittest.PrepareTestEnv(t) + + 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) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) + contexttest.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) { + unittest.PrepareTestEnv(t) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + branch := repo.DefaultBranch + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + commit, _ := gitRepo.GetBranchCommit(branch) + var expectedTreePath string // 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/find.go b/routers/web/repo/find.go new file mode 100644 index 0000000..9da4237 --- /dev/null +++ b/routers/web/repo/find.go @@ -0,0 +1,24 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +const ( + tplFindFiles base.TplName = "repo/find/files" +) + +// FindFiles render the page to find repository files +func FindFiles(ctx *context.Context) { + path := ctx.Params("*") + ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + util.PathEscapeSegments(path) + ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + util.PathEscapeSegments(path) + ctx.HTML(http.StatusOK, tplFindFiles) +} diff --git a/routers/web/repo/flags/manage.go b/routers/web/repo/flags/manage.go new file mode 100644 index 0000000..377a5c2 --- /dev/null +++ b/routers/web/repo/flags/manage.go @@ -0,0 +1,49 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplRepoFlags base.TplName = "repo/flags" +) + +func Manage(ctx *context.Context) { + ctx.Data["IsRepoFlagsPage"] = true + ctx.Data["Title"] = ctx.Tr("repo.admin.manage_flags") + + flags := map[string]bool{} + for _, f := range setting.Repository.SettableFlags { + flags[f] = false + } + repoFlags, _ := ctx.Repo.Repository.ListFlags(ctx) + for _, f := range repoFlags { + flags[f.Name] = true + } + + ctx.Data["Flags"] = flags + + ctx.HTML(http.StatusOK, tplRepoFlags) +} + +func ManagePost(ctx *context.Context) { + newFlags := ctx.FormStrings("flags") + + err := ctx.Repo.Repository.ReplaceAllFlags(ctx, newFlags) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.admin.failed_to_replace_flags")) + log.Error("Error replacing repository flags for repo %d: %v", ctx.Repo.Repository.ID, err) + } else { + ctx.Flash.Success(ctx.Tr("repo.admin.flags_replaced")) + } + + ctx.Redirect(ctx.Repo.Repository.HTMLURL() + "/flags") +} diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go new file mode 100644 index 0000000..a082498 --- /dev/null +++ b/routers/web/repo/githttp.go @@ -0,0 +1,599 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "compress/gzip" + gocontext "context" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/go-chi/cors" +) + +func HTTPGitEnabledHandler(ctx *context.Context) { + if setting.Repository.DisableHTTPGit { + ctx.Resp.WriteHeader(http.StatusForbidden) + _, _ = ctx.Resp.Write([]byte("Interacting with repositories by HTTP protocol is not allowed")) + } +} + +func CorsHandler() func(next http.Handler) http.Handler { + if setting.Repository.AccessControlAllowOrigin != "" { + return cors.Handler(cors.Options{ + AllowedOrigins: []string{setting.Repository.AccessControlAllowOrigin}, + AllowedHeaders: []string{"Content-Type", "Authorization", "User-Agent"}, + }) + } + return func(next http.Handler) http.Handler { + return next + } +} + +// httpBase implementation git smart HTTP protocol +func httpBase(ctx *context.Context) *serviceHandler { + username := ctx.Params(":username") + reponame := strings.TrimSuffix(ctx.Params(":reponame"), ".git") + + if ctx.FormString("go-get") == "1" { + context.EarlyResponseForGoGetMeta(ctx) + return nil + } + + var isPull, receivePack bool + service := ctx.FormString("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 perm.AccessMode + if isPull { + accessMode = perm.AccessModeRead + } else { + accessMode = perm.AccessModeWrite + } + + isWiki := false + unitType := unit.TypeCode + + if strings.HasSuffix(reponame, ".wiki") { + isWiki = true + unitType = unit.TypeWiki + reponame = reponame[:len(reponame)-5] + } + + owner := ctx.ContextUser + if !owner.IsOrganization() && !owner.IsActive { + ctx.PlainText(http.StatusForbidden, "Repository cannot be accessed. You cannot push or open issues/pull-requests.") + return nil + } + + repoExist := true + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + ctx.ServerError("GetRepositoryByName", err) + return nil + } + + if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil { + context.RedirectToRepo(ctx.Base, redirectRepoID) + return nil + } + repoExist = false + } + + // Don't allow pushing if the repo is archived + if repoExist && repo.IsArchived && !isPull { + ctx.PlainText(http.StatusForbidden, "This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.") + return nil + } + + // 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.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return nil + } + + 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="Gitea"`) + ctx.Error(http.StatusUnauthorized) + return nil + } + + context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) + if ctx.Written() { + return nil + } + + if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true && ctx.Data["IsActionsToken"] != true { + _, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.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.PlainText(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 nil + } else if !auth_model.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("IsErrTwoFactorNotEnrolled", err) + return nil + } + } + + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + ctx.PlainText(http.StatusForbidden, "Your account is disabled.") + return nil + } + + environ = []string{ + repo_module.EnvRepoUsername + "=" + username, + repo_module.EnvRepoName + "=" + reponame, + repo_module.EnvPusherName + "=" + ctx.Doer.Name, + repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID), + repo_module.EnvAppURL + "=" + setting.AppURL, + } + + if repoExist { + // Because of special ref "refs/for" .. , need delay write permission check + if git.SupportProcReceive { + accessMode = perm.AccessModeRead + } + + if ctx.Data["IsActionsToken"] == true { + taskID := ctx.Data["ActionsTaskID"].(int64) + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + ctx.ServerError("GetTaskByID", err) + return nil + } + if task.RepoID != repo.ID { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return nil + } + + if task.IsForkPullRequest { + if accessMode > perm.AccessModeRead { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return nil + } + environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeRead)) + } else { + if accessMode > perm.AccessModeWrite { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return nil + } + environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeWrite)) + } + } else { + p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + + if !p.CanAccess(accessMode, unitType) { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + } + + if !isPull && repo.IsMirror { + ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") + return nil + } + } + + if !ctx.Doer.KeepEmailPrivate { + environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email) + } + + if isWiki { + environ = append(environ, repo_module.EnvRepoIsWiki+"=true") + } else { + environ = append(environ, repo_module.EnvRepoIsWiki+"=false") + } + } + + if !repoExist { + if !receivePack { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + + if isWiki { // you cannot send wiki operation before create the repository + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + + if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg { + ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for organizations.") + return nil + } + if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser { + ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for users.") + return nil + } + + // Return dummy payload if GET receive-pack + if ctx.Req.Method == http.MethodGet { + dummyInfoRefs(ctx) + return nil + } + + repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame) + if err != nil { + log.Error("pushCreateRepo: %v", err) + ctx.Status(http.StatusNotFound) + return nil + } + } + + if isWiki { + // Ensure the wiki is enabled before we allow access to it + if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + ctx.PlainText(http.StatusForbidden, "repository wiki is disabled") + return nil + } + log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err) + ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) + return nil + } + } + + environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) + + ctx.Req.URL.Path = strings.ToLower(ctx.Req.URL.Path) // blue: In case some repo name has upper case name + + return &serviceHandler{repo, isWiki, environ} +} + +var ( + infoRefsCache []byte + infoRefsOnce sync.Once +) + +func dummyInfoRefs(ctx *context.Context) { + infoRefsOnce.Do(func() { + tmpDir, err := os.MkdirTemp(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(ctx, tmpDir, true, git.Sha1ObjectFormat.Name()); err != nil { + log.Error("Failed to init bare repo for git-receive-pack cache: %v", err) + return + } + + refs, _, err := git.NewCommand(ctx, "receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Dir: tmpDir}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + log.Debug("populating infoRefsCache: \n%s", string(refs)) + infoRefsCache = refs + }) + + ctx.RespHeader().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + ctx.RespHeader().Set("Pragma", "no-cache") + ctx.RespHeader().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") + ctx.RespHeader().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 serviceHandler struct { + repo *repo_model.Repository + isWiki bool + environ []string +} + +func (h *serviceHandler) getRepoDir() string { + if h.isWiki { + return h.repo.WikiPath() + } + return h.repo.RepoPath() +} + +func setHeaderNoCache(ctx *context.Context) { + ctx.Resp.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + ctx.Resp.Header().Set("Pragma", "no-cache") + ctx.Resp.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") +} + +func setHeaderCacheForever(ctx *context.Context) { + now := time.Now().Unix() + expires := now + 31536000 + ctx.Resp.Header().Set("Date", fmt.Sprintf("%d", now)) + ctx.Resp.Header().Set("Expires", fmt.Sprintf("%d", expires)) + ctx.Resp.Header().Set("Cache-Control", "public, max-age=31536000") +} + +func containsParentDirectorySeparator(v string) bool { + if !strings.Contains(v, "..") { + return false + } + for _, ent := range strings.FieldsFunc(v, isSlashRune) { + if ent == ".." { + return true + } + } + return false +} + +func isSlashRune(r rune) bool { return r == '/' || r == '\\' } + +func (h *serviceHandler) sendFile(ctx *context.Context, contentType, file string) { + if containsParentDirectorySeparator(file) { + log.Error("request file path contains invalid path: %v", file) + ctx.Resp.WriteHeader(http.StatusBadRequest) + return + } + reqFile := filepath.Join(h.getRepoDir(), file) + + fi, err := os.Stat(reqFile) + if os.IsNotExist(err) { + ctx.Resp.WriteHeader(http.StatusNotFound) + return + } + + ctx.Resp.Header().Set("Content-Type", contentType) + ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat + ctx.Resp.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) + http.ServeFile(ctx.Resp, ctx.Req, 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 prepareGitCmdWithAllowedService(ctx *context.Context, service string) (*git.Command, error) { + if service == "receive-pack" { + return git.NewCommand(ctx, "receive-pack"), nil + } + if service == "upload-pack" { + return git.NewCommand(ctx, "upload-pack"), nil + } + + return nil, fmt.Errorf("service %q is not allowed", service) +} + +func serviceRPC(ctx *context.Context, h *serviceHandler, service string) { + defer func() { + if err := ctx.Req.Body.Close(); err != nil { + log.Error("serviceRPC: Close: %v", err) + } + }() + + expectedContentType := fmt.Sprintf("application/x-git-%s-request", service) + if ctx.Req.Header.Get("Content-Type") != expectedContentType { + log.Error("Content-Type (%q) doesn't match expected: %q", ctx.Req.Header.Get("Content-Type"), expectedContentType) + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + + cmd, err := prepareGitCmdWithAllowedService(ctx, service) + if err != nil { + log.Error("Failed to prepareGitCmdWithService: %v", err) + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + + ctx.Resp.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) + + reqBody := ctx.Req.Body + + // Handle GZIP. + if ctx.Req.Header.Get("Content-Encoding") == "gzip" { + reqBody, err = gzip.NewReader(reqBody) + if err != nil { + log.Error("Fail to create gzip reader: %v", err) + ctx.Resp.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 := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) + } + + var stderr bytes.Buffer + cmd.AddArguments("--stateless-rpc").AddDynamicArguments(h.getRepoDir()) + cmd.SetDescription(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.getRepoDir())) + if err := cmd.Run(&git.RunOpts{ + Dir: h.getRepoDir(), + Env: append(os.Environ(), h.environ...), + Stdout: ctx.Resp, + Stdin: reqBody, + Stderr: &stderr, + UseContextTimeout: true, + }); err != nil { + if err.Error() != "signal: killed" { + log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.getRepoDir(), err, stderr.String()) + } + return + } +} + +// ServiceUploadPack implements Git Smart HTTP protocol +func ServiceUploadPack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(ctx, h, "upload-pack") + } +} + +// ServiceReceivePack implements Git Smart HTTP protocol +func ServiceReceivePack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(ctx, h, "receive-pack") + } +} + +func getServiceType(ctx *context.Context) string { + serviceType := ctx.Req.FormValue("service") + if !strings.HasPrefix(serviceType, "git-") { + return "" + } + return strings.TrimPrefix(serviceType, "git-") +} + +func updateServerInfo(ctx gocontext.Context, dir string) []byte { + out, _, err := git.NewCommand(ctx, "update-server-info").RunStdBytes(&git.RunOpts{Dir: 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 + } + setHeaderNoCache(ctx) + service := getServiceType(ctx) + cmd, err := prepareGitCmdWithAllowedService(ctx, service) + if err == nil { + if protocol := ctx.Req.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 := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Env: h.environ, Dir: h.getRepoDir()}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + ctx.Resp.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service)) + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(packetWrite("# service=git-" + service + "\n")) + _, _ = ctx.Resp.Write([]byte("0000")) + _, _ = ctx.Resp.Write(refs) + } else { + updateServerInfo(ctx, h.getRepoDir()) + h.sendFile(ctx, "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 { + setHeaderNoCache(ctx) + file := ctx.Params("file") + if file != "" { + h.sendFile(ctx, "text/plain", "objects/info/"+file) + } else { + h.sendFile(ctx, "text/plain", p) + } + } + } +} + +// GetInfoPacks implements Git dumb HTTP +func GetInfoPacks(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "text/plain; charset=utf-8", "objects/info/packs") + } +} + +// GetLooseObject implements Git dumb HTTP +func GetLooseObject(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "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 { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "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 { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") + } +} diff --git a/routers/web/repo/githttp_test.go b/routers/web/repo/githttp_test.go new file mode 100644 index 0000000..5ba8de3 --- /dev/null +++ b/routers/web/repo/githttp_test.go @@ -0,0 +1,42 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContainsParentDirectorySeparator(t *testing.T) { + tests := []struct { + v string + b bool + }{ + { + v: `user2/repo1/info/refs`, + b: false, + }, + { + v: `user2/repo1/HEAD`, + b: false, + }, + { + v: `user2/repo1/some.../strange_file...mp3`, + b: false, + }, + { + v: `user2/repo1/../../custom/conf/app.ini`, + b: true, + }, + { + v: `user2/repo1/objects/info/..\..\..\..\custom\conf\app.ini`, + b: true, + }, + } + + for i := range tests { + assert.EqualValues(t, tests[i].b, containsParentDirectorySeparator(tests[i].v)) + } +} diff --git a/routers/web/repo/helper.go b/routers/web/repo/helper.go new file mode 100644 index 0000000..5e1e116 --- /dev/null +++ b/routers/web/repo/helper.go @@ -0,0 +1,44 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/url" + "sort" + + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" +) + +func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { + if doer != nil { + sort.Slice(users, func(i, j int) bool { + if users[i].ID == users[j].ID { + return false + } + return users[i].ID == doer.ID // if users[i] is self, put it before others, so less=true + }) + } + return users +} + +func HandleGitError(ctx *context.Context, msg string, err error) { + if git.IsErrNotExist(err) { + refType := "" + switch { + case ctx.Repo.IsViewBranch: + refType = "branch" + case ctx.Repo.IsViewTag: + refType = "tag" + case ctx.Repo.IsViewCommit: + refType = "commit" + } + ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found_"+refType, ctx.Repo.TreePath, url.PathEscape(ctx.Repo.RefName)) + ctx.Data["NotFoundGoBackURL"] = ctx.Repo.RepoLink + "/src/" + refType + "/" + url.PathEscape(ctx.Repo.RefName) + ctx.NotFound(msg, err) + } else { + ctx.ServerError(msg, err) + } +} diff --git a/routers/web/repo/helper_test.go b/routers/web/repo/helper_test.go new file mode 100644 index 0000000..978758e --- /dev/null +++ b/routers/web/repo/helper_test.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestMakeSelfOnTop(t *testing.T) { + users := MakeSelfOnTop(nil, []*user.User{{ID: 2}, {ID: 1}}) + assert.Len(t, users, 2) + assert.EqualValues(t, 2, users[0].ID) + + users = MakeSelfOnTop(&user.User{ID: 1}, []*user.User{{ID: 2}, {ID: 1}}) + assert.Len(t, users, 2) + assert.EqualValues(t, 1, users[0].ID) + + users = MakeSelfOnTop(&user.User{ID: 2}, []*user.User{{ID: 2}, {ID: 1}}) + assert.Len(t, users, 2) + assert.EqualValues(t, 2, users[0].ID) +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go new file mode 100644 index 0000000..5d13ccc --- /dev/null +++ b/routers/web/repo/issue.go @@ -0,0 +1,3822 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + stdCtx "context" + "errors" + "fmt" + "html/template" + "math/big" + "net/http" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" + pull_model "code.gitea.io/gitea/models/pull" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/git" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + issue_template "code.gitea.io/gitea/modules/issue/template" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/templates/vars" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + + "gitea.com/go-chi/binding" +) + +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" +) + +// IssueTemplateCandidates issue templates +var IssueTemplateCandidates = []string{ + "ISSUE_TEMPLATE.md", + "ISSUE_TEMPLATE.yaml", + "ISSUE_TEMPLATE.yml", + "issue_template.md", + "issue_template.yaml", + "issue_template.yml", + ".forgejo/ISSUE_TEMPLATE.md", + ".forgejo/ISSUE_TEMPLATE.yaml", + ".forgejo/ISSUE_TEMPLATE.yml", + ".forgejo/issue_template.md", + ".forgejo/issue_template.yaml", + ".forgejo/issue_template.yml", + ".gitea/ISSUE_TEMPLATE.md", + ".gitea/ISSUE_TEMPLATE.yaml", + ".gitea/ISSUE_TEMPLATE.yml", + ".gitea/issue_template.md", + ".gitea/issue_template.yaml", + ".gitea/issue_template.yml", + ".github/ISSUE_TEMPLATE.md", + ".github/ISSUE_TEMPLATE.yaml", + ".github/ISSUE_TEMPLATE.yml", + ".github/issue_template.md", + ".github/issue_template.yaml", + ".github/issue_template.yml", +} + +// 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.Doer.IsAdmin { + ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) + ctx.Redirect(issue.Link()) + return + } +} + +// MustEnableIssues check if repository enable internal issues +func MustEnableIssues(ctx *context.Context) { + if !ctx.Repo.CanRead(unit.TypeIssues) && + !ctx.Repo.CanRead(unit.TypeExternalTracker) { + ctx.NotFound("MustEnableIssues", nil) + return + } + + unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) + 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(unit.TypePullRequests) { + ctx.NotFound("MustAllowPulls", nil) + return + } + + // User can send pull request if owns a forked repository. + if ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { + ctx.Repo.PullRequest.Allowed = true + ctx.Repo.PullRequest.HeadInfoSubURL = url.PathEscape(ctx.Doer.Name) + ":" + util.PathEscapeSegments(ctx.Repo.BranchName) + } +} + +func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { + var err error + viewType := ctx.FormString("type") + sortType := ctx.FormString("sort") + types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested", "reviewed_by"} + if !util.SliceContainsString(types, viewType, true) { + viewType = "all" + } + + var ( + assigneeID = ctx.FormInt64("assignee") + posterID = ctx.FormInt64("poster") + mentionedID int64 + reviewRequestedID int64 + reviewedID int64 + ) + + if ctx.IsSigned { + switch viewType { + case "created_by": + posterID = ctx.Doer.ID + case "mentioned": + mentionedID = ctx.Doer.ID + case "assigned": + assigneeID = ctx.Doer.ID + case "review_requested": + reviewRequestedID = ctx.Doer.ID + case "reviewed_by": + reviewedID = ctx.Doer.ID + } + } + + repo := ctx.Repo.Repository + var labelIDs []int64 + // 1,-2 means including label 1 and excluding label 2 + // 0 means issues with no label + // blank means labels will not be filtered for issues + selectLabels := ctx.FormString("labels") + if selectLabels == "" { + ctx.Data["AllLabels"] = true + } else if selectLabels == "0" { + ctx.Data["NoLabel"] = true + } + if len(selectLabels) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) + } + } + + keyword := strings.Trim(ctx.FormString("q"), " ") + if bytes.Contains([]byte(keyword), []byte{0x00}) { + keyword = "" + } + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + var mileIDs []int64 + if milestoneID > 0 || milestoneID == db.NoConditionID { // -1 to get those issues which have no any milestone assigned + mileIDs = []int64{milestoneID} + } + + var issueStats *issues_model.IssueStats + statsOpts := &issues_model.IssuesOptions{ + RepoIDs: []int64{repo.ID}, + LabelIDs: labelIDs, + MilestoneIDs: mileIDs, + ProjectID: projectID, + AssigneeID: assigneeID, + MentionedID: mentionedID, + PosterID: posterID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + IsPull: isPullOption, + IssueIDs: nil, + } + if keyword != "" { + allIssueIDs, err := issueIDsFromSearch(ctx, keyword, isFuzzy, statsOpts) + if err != nil { + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) + return + } + ctx.Data["IssueIndexerUnavailable"] = true + return + } + statsOpts.IssueIDs = allIssueIDs + } + if keyword != "" && len(statsOpts.IssueIDs) == 0 { + // So it did search with the keyword, but no issue found. + // Just set issueStats to empty. + issueStats = &issues_model.IssueStats{} + } else { + // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. + // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. + issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) + if err != nil { + ctx.ServerError("GetIssueStats", err) + return + } + } + + var isShowClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isShowClosed = optional.Some(true) + case "all": + isShowClosed = optional.None[bool]() + default: + isShowClosed = optional.Some(false) + } + // if there are closed issues and no open issues, default to showing all issues + if len(ctx.FormString("state")) == 0 && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 { + isShowClosed = optional.None[bool]() + } + + if repo.IsTimetrackerEnabled(ctx) { + totalTrackedTime, err := issues_model.GetIssueTotalTrackedTime(ctx, statsOpts, isShowClosed) + if err != nil { + ctx.ServerError("GetIssueTotalTrackedTime", err) + return + } + ctx.Data["TotalTrackedTime"] = totalTrackedTime + } + + archived := ctx.FormBool("archived") + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + var total int + switch { + case isShowClosed.Value(): + total = int(issueStats.ClosedCount) + case !isShowClosed.Has(): + total = int(issueStats.OpenCount + issueStats.ClosedCount) + default: + total = int(issueStats.OpenCount) + } + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) + + var issues issues_model.IssueList + { + ids, err := issueIDsFromSearch(ctx, keyword, isFuzzy, &issues_model.IssuesOptions{ + Paginator: &db.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: setting.UI.IssuePagingNum, + }, + RepoIDs: []int64{repo.ID}, + AssigneeID: assigneeID, + PosterID: posterID, + MentionedID: mentionedID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + MilestoneIDs: mileIDs, + ProjectID: projectID, + IsClosed: isShowClosed, + IsPull: isPullOption, + LabelIDs: labelIDs, + SortType: sortType, + }) + if err != nil { + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) + return + } + ctx.Data["IssueIndexerUnavailable"] = true + return + } + issues, err = issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return + } + } + + approvalCounts, err := issues.GetApprovalCounts(ctx) + if err != nil { + ctx.ServerError("ApprovalCounts", err) + return + } + + if ctx.IsSigned { + if err := issues.LoadIsRead(ctx, ctx.Doer.ID); err != nil { + ctx.ServerError("LoadIsRead", err) + return + } + } else { + for i := range issues { + issues[i].IsRead = true + } + } + + commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesAllCommitStatus", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + + if err := issues.LoadAttributes(ctx); err != nil { + ctx.ServerError("issues.LoadAttributes", err) + return + } + + ctx.Data["Issues"] = issues + ctx.Data["CommitLastStatus"] = lastStatus + ctx.Data["CommitStatuses"] = commitStatuses + + // Get assignees. + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, repo) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + handleTeamMentions(ctx) + if ctx.Written() { + return + } + + labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + + if repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + ctx.Data["OrgLabels"] = orgLabels + labels = append(labels, orgLabels...) + } + + // Get the exclusive scope for every label ID + labelExclusiveScopes := make([]string, 0, len(labelIDs)) + for _, labelID := range labelIDs { + foundExclusiveScope := false + for _, label := range labels { + if label.ID == labelID || label.ID == -labelID { + labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope()) + foundExclusiveScope = true + break + } + } + if !foundExclusiveScope { + labelExclusiveScopes = append(labelExclusiveScopes, "") + } + } + + for _, l := range labels { + l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) + } + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + + if ctx.FormInt64("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 := issues_model.ReviewTypeApprove + if typ == "reject" { + reviewTyp = issues_model.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = issues_model.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + + retrieveProjects(ctx, repo) + if ctx.Written() { + return + } + + pinned, err := issues_model.GetPinnedIssues(ctx, repo.ID, isPullOption.Value()) + if err != nil { + ctx.ServerError("GetPinnedIssues", err) + return + } + + ctx.Data["PinnedIssues"] = pinned + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["IssueStats"] = issueStats + ctx.Data["OpenCount"] = issueStats.OpenCount + ctx.Data["ClosedCount"] = issueStats.ClosedCount + linkStr := "%s?q=%s&type=%s&sort=%s&state=%s&labels=%s&milestone=%d&project=%d&assignee=%d&poster=%d&archived=%t" + ctx.Data["AllStatesLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "all", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "open", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "closed", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["SelLabelIDs"] = labelIDs + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["MilestoneID"] = milestoneID + ctx.Data["ProjectID"] = projectID + ctx.Data["AssigneeID"] = assigneeID + ctx.Data["PosterID"] = posterID + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["Keyword"] = keyword + ctx.Data["IsShowClosed"] = isShowClosed + switch { + case isShowClosed.Value(): + ctx.Data["State"] = "closed" + case !isShowClosed.Has(): + ctx.Data["State"] = "all" + default: + ctx.Data["State"] = "open" + } + ctx.Data["ShowArchivedLabels"] = archived + + 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, "project", "ProjectID") + pager.AddParam(ctx, "assignee", "AssigneeID") + pager.AddParam(ctx, "poster", "PosterID") + pager.AddParam(ctx, "archived", "ShowArchivedLabels") + pager.AddParam(ctx, "fuzzy", "IsFuzzy") + + ctx.Data["Page"] = pager +} + +func issueIDsFromSearch(ctx *context.Context, keyword string, fuzzy bool, opts *issues_model.IssuesOptions) ([]int64, error) { + ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy( + func(o *issue_indexer.SearchOptions) { + o.IsFuzzyKeyword = fuzzy + }, + )) + if err != nil { + return nil, fmt.Errorf("SearchIssues: %w", err) + } + return ids, nil +} + +// 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"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + } + + issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) + if ctx.Written() { + return + } + + renderMilestones(ctx) + if ctx.Written() { + return + } + + ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList) + + ctx.HTML(http.StatusOK, tplIssues) +} + +func renderMilestones(ctx *context.Context) { + // Get milestones + milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("GetAllRepoMilestones", err) + return + } + + openMilestones, closedMilestones := issues_model.MilestoneList{}, issues_model.MilestoneList{} + for _, milestone := range milestones { + if milestone.IsClosed { + closedMilestones = append(closedMilestones, milestone) + } else { + openMilestones = append(openMilestones, milestone) + } + } + ctx.Data["OpenMilestones"] = openMilestones + ctx.Data["ClosedMilestones"] = closedMilestones +} + +// RetrieveRepoMilestonesAndAssignees find all the milestones and assignees of a repository +func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.Repository) { + var err error + ctx.Data["OpenMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(false), + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + ctx.Data["ClosedMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(true), + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, repo) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + handleTeamMentions(ctx) +} + +func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { + // Distinguish whether the owner of the repository + // is an individual or an organization + repoOwnerType := project_model.TypeIndividual + if repo.Owner.IsOrganization() { + repoOwnerType = project_model.TypeOrganization + } + var err error + projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + RepoID: repo.ID, + IsClosed: optional.Some(false), + Type: project_model.TypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + projects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: repo.OwnerID, + IsClosed: optional.Some(false), + Type: repoOwnerType, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["OpenProjects"] = append(projects, projects2...) + + projects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + RepoID: repo.ID, + IsClosed: optional.Some(true), + Type: project_model.TypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + projects2, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: repo.OwnerID, + IsClosed: optional.Some(true), + Type: repoOwnerType, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["ClosedProjects"] = append(projects, projects2...) +} + +// repoReviewerSelection items to bee shown +type repoReviewerSelection struct { + IsTeam bool + Team *organization.Team + User *user_model.User + Review *issues_model.Review + CanChange bool + Checked bool + ItemID int64 +} + +// RetrieveRepoReviewers find all reviewers of a repository +func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, canChooseReviewer bool) { + ctx.Data["CanChooseReviewer"] = canChooseReviewer + + originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID) + if err != nil { + ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err) + return + } + ctx.Data["OriginalReviews"] = originalAuthorReviews + + reviews, err := issues_model.GetReviewsByIssueID(ctx, issue.ID) + if err != nil { + ctx.ServerError("GetReviewersByIssueID", err) + return + } + + if len(reviews) == 0 && !canChooseReviewer { + return + } + + var ( + pullReviews []*repoReviewerSelection + reviewersResult []*repoReviewerSelection + teamReviewersResult []*repoReviewerSelection + teamReviewers []*organization.Team + reviewers []*user_model.User + ) + + if canChooseReviewer { + posterID := issue.PosterID + if issue.OriginalAuthorID > 0 { + posterID = 0 + } + + reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID) + if err != nil { + ctx.ServerError("GetReviewers", err) + return + } + + teamReviewers, err = repo_service.GetReviewerTeams(ctx, repo) + 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 == issues_model.ReviewTypeRequest, + Review: review, + ItemID: review.ReviewerID, + } + if review.ReviewerTeamID > 0 { + tmp.IsTeam = true + tmp.ItemID = -review.ReviewerTeamID + } + + if canChooseReviewer { + // Users who can choose reviewers can also remove review requests + tmp.CanChange = true + } else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest { + // A user can refuse review requests + 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(ctx); err != nil { + if user_model.IsErrUserNotExist(err) { + continue + } + ctx.ServerError("LoadReviewer", err) + return + } + item.User = item.Review.Reviewer + } else if item.Review.ReviewerTeamID > 0 { + if err = item.Review.LoadReviewerTeam(ctx); err != nil { + if organization.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 *repo_model.Repository, isPull bool) []*issues_model.Label { + if !ctx.Repo.CanWriteIssuesOrPulls(isPull) { + return nil + } + + labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return nil + } + ctx.Data["Labels"] = labels + if repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.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 + } + + PrepareBranchList(ctx) + if ctx.Written() { + return nil + } + + // Contains true if the user can create issue dependencies + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull) + + return labels +} + +// Tries to load and set an issue template. The first return value indicates if a template was loaded. +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) (bool, map[string]error) { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + return false, nil + } + + templateCandidates := make([]string, 0, 1+len(possibleFiles)) + if t := ctx.FormString("template"); t != "" { + templateCandidates = append(templateCandidates, t) + } + templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback + + templateErrs := map[string]error{} + for _, filename := range templateCandidates { + if ok, _ := commit.HasFile(filename); !ok { + continue + } + template, err := issue_template.UnmarshalFromCommit(commit, filename) + if err != nil { + templateErrs[filename] = err + continue + } + ctx.Data[issueTemplateTitleKey] = template.Title + ctx.Data[ctxDataKey] = template.Content + + if template.Type() == api.IssueTemplateTypeYaml { + // Replace field default values by values from query + for _, field := range template.Fields { + fieldValue := ctx.FormString("field:" + field.ID) + if fieldValue != "" { + field.Attributes["value"] = fieldValue + } + } + + ctx.Data["Fields"] = template.Fields + ctx.Data["TemplateFile"] = template.FileName + } + labelIDs := make([]string, 0, len(template.Labels)) + if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil { + ctx.Data["Labels"] = repoLabels + if ctx.Repo.Owner.IsOrganization() { + if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil { + ctx.Data["OrgLabels"] = orgLabels + repoLabels = append(repoLabels, orgLabels...) + } + } + + for _, metaLabel := range template.Labels { + for _, repoLabel := range repoLabels { + if strings.EqualFold(repoLabel.Name, metaLabel) { + repoLabel.IsChecked = true + labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10)) + break + } + } + } + } + + if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref> + template.Ref = git.BranchPrefix + template.Ref + } + ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0 + ctx.Data["label_ids"] = strings.Join(labelIDs, ",") + ctx.Data["Reference"] = template.Ref + ctx.Data["RefEndName"] = git.RefName(template.Ref).ShortName() + return true, templateErrs + } + return false, templateErrs +} + +// NewIssue render creating issue page +func NewIssue(ctx *context.Context) { + issueConfig, _ := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + hasTemplates := issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = hasTemplates + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + title := ctx.FormString("title") + ctx.Data["TitleQuery"] = title + body := ctx.FormString("body") + ctx.Data["BodyQuery"] = body + + isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects) + ctx.Data["IsProjectsEnabled"] = isProjectsEnabled + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + milestoneID := ctx.FormInt64("milestone") + if milestoneID > 0 { + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) + if err != nil { + log.Error("GetMilestoneByID: %d: %v", milestoneID, err) + } else { + ctx.Data["milestone_id"] = milestoneID + ctx.Data["Milestone"] = milestone + } + } + + projectID := ctx.FormInt64("project") + if projectID > 0 && isProjectsEnabled { + project, err := project_model.GetProjectByID(ctx, 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 + } + + if len(ctx.Req.URL.Query().Get("project")) > 0 { + ctx.Data["redirect_after_creation"] = "project" + } + } + + RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + _, templateErrs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) + for k, v := range errs { + templateErrs[k] = v + } + if ctx.Written() { + return + } + + if len(templateErrs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) + } + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues) + + if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded { + // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters. + ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) + return + } + + ctx.HTML(http.StatusOK, tplIssueNew) +} + +func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) template.HTML { + var files []string + for k := range errs { + files = append(files, k) + } + sort.Strings(files) // keep the output stable + + var lines []string + for _, file := range files { + lines = append(lines, fmt.Sprintf("%s: %v", file, errs[file])) + } + + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.issues.choose.ignore_invalid_templates"), + "Summary": ctx.Tr("repo.issues.choose.invalid_templates", len(errs)), + "Details": utils.SanitizeFlashErrorString(strings.Join(lines, "\n")), + }) + if err != nil { + log.Debug("render flash error: %v", err) + flashError = ctx.Locale.Tr("repo.issues.choose.ignore_invalid_templates") + } + return flashError +} + +// NewIssueChooseTemplate render creating issue from template page +func NewIssueChooseTemplate(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + + issueTemplates, errs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["IssueTemplates"] = issueTemplates + + if len(errs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, errs), true) + } + + if !issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) { + // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if no template here, just redirect to the "issues/new" page with these parameters. + ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) + return + } + + issueConfig, err := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["IssueConfig"] = issueConfig + ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here + + ctx.Data["milestone"] = ctx.FormInt64("milestone") + ctx.Data["project"] = ctx.FormInt64("project") + + ctx.HTML(http.StatusOK, tplIssueChoose) +} + +// DeleteIssue deletes an issue +func DeleteIssue(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if err := issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { + ctx.ServerError("DeleteIssueByID", err) + return + } + + if issue.IsPull { + ctx.Redirect(fmt.Sprintf("%s/pulls", ctx.Repo.Repository.Link()), http.StatusSeeOther) + return + } + + ctx.Redirect(fmt.Sprintf("%s/issues", ctx.Repo.Repository.Link()), http.StatusSeeOther) +} + +// ValidateRepoMetas check and returns repository's meta information +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 := make(container.Set[int64]) + labelIDMark.AddMultiple(labelIDs...) + + for i := range labels { + if labelIDMark.Contains(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 { + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) + if err != nil { + ctx.ServerError("GetMilestoneByID", err) + return nil, nil, 0, 0 + } + if milestone.RepoID != repo.ID { + ctx.ServerError("GetMilestoneByID", err) + return nil, nil, 0, 0 + } + ctx.Data["Milestone"] = milestone + ctx.Data["milestone_id"] = milestoneID + } + + if form.ProjectID > 0 { + p, err := project_model.GetProjectByID(ctx, form.ProjectID) + if err != nil { + ctx.ServerError("GetProjectByID", err) + return nil, nil, 0, 0 + } + if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID { + 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 := user_model.GetUserByID(ctx, aID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return nil, nil, 0, 0 + } + + valid, err := access_model.CanBeAssigned(ctx, assignee, repo, isPull) + if err != nil { + ctx.ServerError("CanBeAssigned", err) + return nil, nil, 0, 0 + } + + if !valid { + ctx.ServerError("canBeAssigned", repo_model.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"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + 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.JSONError(ctx.GetErrMsg()) + return + } + + if util.IsEmptyString(form.Title) { + ctx.JSONError(ctx.Tr("repo.issues.new.title_empty")) + return + } + + content := form.Content + if filename := ctx.Req.Form.Get("template-file"); filename != "" { + if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil { + content = issue_template.RenderToMarkdown(template, ctx.Req.Form) + } + } + + issue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Title: form.Title, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, + MilestoneID: milestoneID, + Content: content, + Ref: form.Ref, + } + + if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.JSONError(ctx.Tr("repo.issues.blocked_by_user")) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + return + } + ctx.ServerError("NewIssue", err) + return + } + + if projectID > 0 { + if !ctx.Repo.CanRead(unit.TypeProjects) { + // User must also be able to see the project. + ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") + return + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + + log.Trace("Issue created: %d/%d", repo.ID, issue.ID) + if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10)) + } else { + ctx.JSONRedirect(issue.Link()) + } +} + +// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue +func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) { + roleDescriptor := issues_model.RoleDescriptor{} + + if hasOriginalAuthor { + return roleDescriptor, nil + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, poster) + if err != nil { + return roleDescriptor, err + } + + // If the poster is the actual poster of the issue, enable Poster role. + roleDescriptor.IsPoster = issue.IsPoster(poster.ID) + + // Check if the poster is owner of the repo. + if perm.IsOwner() { + // If the poster isn't an admin, enable the owner role. + if !poster.IsAdmin { + roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner + return roleDescriptor, nil + } + + // Otherwise check if poster is the real repo admin. + ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster) + if err != nil { + return roleDescriptor, err + } + if ok { + roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner + return roleDescriptor, nil + } + } + + // If repo is organization, check Member role + if err := repo.LoadOwner(ctx); err != nil { + return roleDescriptor, err + } + if repo.Owner.IsOrganization() { + if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil { + return roleDescriptor, err + } else if isMember { + roleDescriptor.RoleInRepo = issues_model.RoleRepoMember + return roleDescriptor, nil + } + } + + // If the poster is the collaborator of the repo + if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil { + return roleDescriptor, err + } else if isCollaborator { + roleDescriptor.RoleInRepo = issues_model.RoleRepoCollaborator + return roleDescriptor, nil + } + + hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID) + if err != nil { + return roleDescriptor, err + } else if hasMergedPR { + roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor + } else if issue.IsPull { + // only display first time contributor in the first opening pull request + roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor + } + + return roleDescriptor, nil +} + +func getBranchData(ctx *context.Context, issue *issues_model.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(ctx) + } +} + +func prepareHiddenCommentType(ctx *context.Context) { + var hiddenCommentTypes *big.Int + if ctx.IsSigned { + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here + } + + ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool { + return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0 + } +} + +// 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(ctx, unit.TypeExternalTracker) + if err == nil && extIssueUnit != nil { + if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { + metas := ctx.Repo.Repository.ComposeMetas(ctx) + metas["index"] = ctx.Params(":index") + res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) + if err != nil { + log.Error("unable to expand template vars for issue url. issue: %s, err: %v", metas["index"], err) + ctx.ServerError("Expand", err) + return + } + ctx.Redirect(res) + return + } + } else if err != nil && !repo_model.IsErrUnitTypeNotExist(err) { + ctx.ServerError("GetUnit", err) + return + } + } + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + if issue.Repo == nil { + issue.Repo = ctx.Repo.Repository + } + + // Make sure type and URL matches. + if ctx.Params(":type") == "issues" && issue.IsPull { + ctx.Redirect(issue.Link()) + return + } else if ctx.Params(":type") == "pulls" && !issue.IsPull { + ctx.Redirect(issue.Link()) + 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"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + } + + if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { + ctx.Data["IssueType"] = "pulls" + } else if !issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.Data["IssueType"] = "issues" + } else { + ctx.Data["IssueType"] = "all" + } + + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + if err = issue.LoadAttributes(ctx); 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, emoji.ReplaceAliases(issue.Title)) + + iw := new(issues_model.IssueWatch) + if ctx.Doer != nil { + iw.UserID = ctx.Doer.ID + iw.IssueID = issue.ID + iw.IsWatching, err = issues_model.CheckIssueWatch(ctx, ctx.Doer, issue) + if err != nil { + ctx.ServerError("CheckIssueWatch", err) + return + } + } + ctx.Data["IssueWatch"] = iw + issue.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, 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(container.Set[int64]) + for _, label := range issue.Labels { + labelIDMark.Add(label.ID) + } + labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + ctx.Data["Labels"] = labels + + if repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.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.Contains(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 := false + if ctx.Doer != nil && ctx.IsSigned { + canChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, issue) + } + + RetrieveRepoReviewers(ctx, repo, issue, canChooseReviewer) + if ctx.Written() { + return + } + } + + if ctx.IsSigned { + // Update issue-user. + if err = activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("ReadBy", err) + return + } + } + + var ( + role issues_model.RoleDescriptor + ok bool + marked = make(map[int64]issues_model.RoleDescriptor) + comment *issues_model.Comment + participants = make([]*user_model.User, 1, 10) + latestCloseCommentID int64 + ) + if ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + if ctx.IsSigned { + // Deal with the stopwatch + ctx.Data["IsStopwatchRunning"] = issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID) + if !ctx.Data["IsStopwatchRunning"].(bool) { + var exists bool + var swIssue *issues_model.Issue + if exists, _, swIssue, err = issues_model.HasUserStopwatch(ctx, ctx.Doer.ID); err != nil { + ctx.ServerError("HasUserStopwatch", err) + return + } + ctx.Data["HasUserStopwatch"] = exists + if exists { + // Add warning if the user has already a stopwatch + // Add link to the issue of the already running stopwatch + ctx.Data["OtherStopwatchURL"] = swIssue.Link() + } + } + ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) + } else { + ctx.Data["CanUseTimetracker"] = false + } + if ctx.Data["WorkingUsers"], err = issues_model.TotalTimesForEachUser(ctx, &issues_model.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil { + ctx.ServerError("TotalTimesForEachUser", err) + return + } + } + + // Check if the user can use the dependencies + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) + + // check if dependencies can be created across repositories + ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies + + if issue.ShowRole, err = roleDescriptor(ctx, repo, issue.Poster, issue, issue.HasOriginalAuthor()); err != nil { + ctx.ServerError("roleDescriptor", err) + return + } + marked[issue.PosterID] = issue.ShowRole + + // Render comments and fetch participants. + participants[0] = issue.Poster + + if err := issue.Comments.LoadAttachmentsByIssue(ctx); err != nil { + ctx.ServerError("LoadAttachmentsByIssue", err) + return + } + if err := issue.Comments.LoadPosters(ctx); err != nil { + ctx.ServerError("LoadPosters", err) + return + } + + for _, comment = range issue.Comments { + comment.Issue = issue + + if comment.Type == issues_model.CommentTypeComment || comment.Type == issues_model.CommentTypeReview { + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + // Check tag. + role, ok = marked[comment.PosterID] + if ok { + comment.ShowRole = role + continue + } + + comment.ShowRole, err = roleDescriptor(ctx, repo, comment.Poster, issue, comment.HasOriginalAuthor()) + if err != nil { + ctx.ServerError("roleDescriptor", err) + return + } + marked[comment.PosterID] = comment.ShowRole + participants = addParticipant(comment.Poster, participants) + } else if comment.Type == issues_model.CommentTypeLabel { + if err = comment.LoadLabel(ctx); err != nil { + ctx.ServerError("LoadLabel", err) + return + } + } else if comment.Type == issues_model.CommentTypeMilestone { + if err = comment.LoadMilestone(ctx); err != nil { + ctx.ServerError("LoadMilestone", err) + return + } + ghostMilestone := &issues_model.Milestone{ + ID: -1, + Name: ctx.Locale.TrString("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 == issues_model.CommentTypeProject { + if err = comment.LoadProject(ctx); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + ghostProject := &project_model.Project{ + ID: project_model.GhostProjectID, + Title: ctx.Locale.TrString("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 == issues_model.CommentTypeAssignees || comment.Type == issues_model.CommentTypeReviewRequest { + if err = comment.LoadAssigneeUserAndTeam(ctx); err != nil { + ctx.ServerError("LoadAssigneeUserAndTeam", err) + return + } + } else if comment.Type == issues_model.CommentTypeRemoveDependency || comment.Type == issues_model.CommentTypeAddDependency { + if err = comment.LoadDepIssueDetails(ctx); err != nil { + if !issues_model.IsErrIssueNotExist(err) { + ctx.ServerError("LoadDepIssueDetails", err) + return + } + } + } else if comment.Type.HasContentSupport() { + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + if err = comment.LoadReview(ctx); err != nil && !issues_model.IsErrReviewNotExist(err) { + ctx.ServerError("LoadReview", err) + return + } + participants = addParticipant(comment.Poster, participants) + if comment.Review == nil { + continue + } + if err = comment.Review.LoadAttributes(ctx); err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.ServerError("Review.LoadAttributes", err) + return + } + comment.Review.Reviewer = user_model.NewGhostUser() + } + if err = comment.Review.LoadCodeComments(ctx); err != nil { + ctx.ServerError("Review.LoadCodeComments", err) + return + } + for _, codeComments := range comment.Review.CodeComments { + for _, lineComments := range codeComments { + for _, c := range lineComments { + // Check tag. + role, ok = marked[c.PosterID] + if ok { + c.ShowRole = role + continue + } + + c.ShowRole, err = roleDescriptor(ctx, repo, c.Poster, issue, c.HasOriginalAuthor()) + if err != nil { + ctx.ServerError("roleDescriptor", err) + return + } + marked[c.PosterID] = c.ShowRole + participants = addParticipant(c.Poster, participants) + } + } + } + if err = comment.LoadResolveDoer(ctx); err != nil { + ctx.ServerError("LoadResolveDoer", err) + return + } + } else if comment.Type == issues_model.CommentTypePullRequestPush { + participants = addParticipant(comment.Poster, participants) + if err = comment.LoadPushCommits(ctx); err != nil { + ctx.ServerError("LoadPushCommits", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for _, commit := range comment.Commits { + if commit.Status == nil { + continue + } + commit.Status.HideActionsURL(ctx) + git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses) + } + } + } else if comment.Type == issues_model.CommentTypeAddTimeManual || + comment.Type == issues_model.CommentTypeStopTracking || + comment.Type == issues_model.CommentTypeDeleteTimeManual { + // drop error since times could be pruned from DB.. + _ = comment.LoadTime(ctx) + if comment.Content != "" { + // Content before v1.21 did store the formatted string instead of seconds, + // so "|" is used as delimiter to mark the new format + if comment.Content[0] != '|' { + // handle old time comments that have formatted text stored + comment.RenderedContent = templates.SanitizeHTML(comment.Content) + comment.Content = "" + } else { + // else it's just a duration in seconds to pass on to the frontend + comment.Content = comment.Content[1:] + } + } + } + + if comment.Type == issues_model.CommentTypeClose || comment.Type == issues_model.CommentTypeMergePull { + // record ID of the latest closed/merged comment. + // if PR is closed, the comments whose type is CommentTypePullRequestPush(29) after latestCloseCommentID won't be rendered. + latestCloseCommentID = comment.ID + } + } + + ctx.Data["LatestCloseCommentID"] = latestCloseCommentID + + // Combine multiple label assignments into a single comment + combineLabelComments(issue) + + getBranchData(ctx, issue) + if issue.IsPull { + pull := issue.PullRequest + pull.Issue = issue + canDelete := false + allowMerge := false + + if ctx.IsSigned { + if err := pull.LoadHeadRepo(ctx); err != nil { + log.Error("LoadHeadRepo: %v", err) + } else if pull.HeadRepo != nil { + perm, err := access_model.GetUserRepoPermission(ctx, pull.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if perm.CanWrite(unit.TypeCode) { + // Check if branch is not protected + if pull.HeadBranch != pull.HeadRepo.DefaultBranch { + if protected, err := git_model.IsBranchProtected(ctx, pull.HeadRepo.ID, pull.HeadBranch); err != nil { + log.Error("IsProtectedBranch: %v", err) + } else if !protected { + canDelete = true + ctx.Data["DeleteBranchLink"] = issue.Link() + "/cleanup" + } + } + ctx.Data["CanWriteToHeadRepo"] = true + } + } + + if err := pull.LoadBaseRepo(ctx); err != nil { + log.Error("LoadBaseRepo: %v", err) + } + perm, err := access_model.GetUserRepoPermission(ctx, pull.BaseRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + allowMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer) + if err != nil { + ctx.ServerError("IsUserAllowedToMerge", err) + return + } + + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + } + + ctx.Data["AllowMerge"] = allowMerge + + prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests) + if err != nil { + ctx.ServerError("GetUnit", err) + return + } + prConfig := prUnit.PullRequestsConfig() + + ctx.Data["AutodetectManualMerge"] = prConfig.AutodetectManualMerge + + var mergeStyle repo_model.MergeStyle + // Check correct values and select default + if ms, ok := ctx.Data["MergeStyle"].(repo_model.MergeStyle); !ok || + !prConfig.IsMergeStyleAllowed(ms) { + defaultMergeStyle := prConfig.GetDefaultMergeStyle() + if prConfig.IsMergeStyleAllowed(defaultMergeStyle) && !ok { + mergeStyle = defaultMergeStyle + } else if prConfig.AllowMerge { + mergeStyle = repo_model.MergeStyleMerge + } else if prConfig.AllowRebase { + mergeStyle = repo_model.MergeStyleRebase + } else if prConfig.AllowRebaseMerge { + mergeStyle = repo_model.MergeStyleRebaseMerge + } else if prConfig.AllowSquash { + mergeStyle = repo_model.MergeStyleSquash + } else if prConfig.AllowFastForwardOnly { + mergeStyle = repo_model.MergeStyleFastForwardOnly + } else if prConfig.AllowManualMerge { + mergeStyle = repo_model.MergeStyleManuallyMerged + } + } + + ctx.Data["MergeStyle"] = mergeStyle + + defaultMergeMessage, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle) + if err != nil { + ctx.ServerError("GetDefaultMergeMessage", err) + return + } + ctx.Data["DefaultMergeMessage"] = defaultMergeMessage + ctx.Data["DefaultMergeBody"] = defaultMergeBody + + defaultSquashMergeMessage, defaultSquashMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash) + if err != nil { + ctx.ServerError("GetDefaultSquashMergeMessage", err) + return + } + ctx.Data["DefaultSquashMergeMessage"] = defaultSquashMergeMessage + ctx.Data["DefaultSquashMergeBody"] = defaultSquashMergeBody + + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) + if err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return + } + ctx.Data["ShowMergeInstructions"] = true + if pb != nil { + pb.Repo = pull.BaseRepo + var showMergeInstructions bool + if ctx.Doer != nil { + showMergeInstructions = pb.CanUserPush(ctx, ctx.Doer) + } + ctx.Data["ProtectedBranch"] = pb + ctx.Data["IsBlockedByApprovals"] = !issues_model.HasEnoughApprovals(ctx, pb, pull) + ctx.Data["IsBlockedByRejection"] = issues_model.MergeBlockedByRejectedReview(ctx, pb, pull) + ctx.Data["IsBlockedByOfficialReviewRequests"] = issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pull) + ctx.Data["IsBlockedByOutdatedBranch"] = issues_model.MergeBlockedByOutdatedBranch(pb, pull) + ctx.Data["GrantedApprovals"] = issues_model.GetGrantedApprovalsCount(ctx, pb, pull) + ctx.Data["RequireSigned"] = pb.RequireSignedCommits + ctx.Data["ChangedProtectedFiles"] = pull.ChangedProtectedFiles + ctx.Data["IsBlockedByChangedProtectedFiles"] = len(pull.ChangedProtectedFiles) != 0 + ctx.Data["ChangedProtectedFilesNum"] = len(pull.ChangedProtectedFiles) + ctx.Data["ShowMergeInstructions"] = showMergeInstructions + } + ctx.Data["WillSign"] = false + if ctx.Doer != nil { + sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, pull.BaseRepo.RepoPath(), pull.BaseBranch, pull.GetGitRefName()) + ctx.Data["WillSign"] = sign + ctx.Data["SigningKey"] = key + if err != nil { + if asymkey_service.IsErrWontSign(err) { + ctx.Data["WontSignReason"] = err.(*asymkey_service.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" + } + + isPullBranchDeletable := canDelete && + pull.HeadRepo != nil && + git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.HeadBranch) && + (!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"]) + + if isPullBranchDeletable && pull.HasMerged { + exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pull.HeadRepoID, pull.HeadBranch) + if err != nil { + ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err) + return + } + + isPullBranchDeletable = !exist + } + ctx.Data["IsPullBranchDeletable"] = isPullBranchDeletable + + stillCanManualMerge := func() bool { + if pull.HasMerged || issue.IsClosed || !ctx.IsSigned { + return false + } + if pull.CanAutoMerge() || pull.IsWorkInProgress(ctx) || pull.IsChecking() { + return false + } + if allowMerge && prConfig.AllowManualMerge { + return true + } + + return false + } + + ctx.Data["StillCanManualMerge"] = stillCanManualMerge() + + // Check if there is a pending pr merge + ctx.Data["HasPendingPullRequestMerge"], ctx.Data["PendingPullRequestMerge"], err = pull_model.GetScheduledMergeByPullID(ctx, pull.ID) + if err != nil { + ctx.ServerError("GetScheduledMergeByPullID", err) + return + } + } + + // Get Dependencies + blockedBy, err := issue.BlockedByDependencies(ctx, db.ListOptions{}) + if err != nil { + ctx.ServerError("BlockedByDependencies", err) + return + } + ctx.Data["BlockedByDependencies"], ctx.Data["BlockedByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blockedBy) + if ctx.Written() { + return + } + + blocking, err := issue.BlockingDependencies(ctx) + if err != nil { + ctx.ServerError("BlockingDependencies", err) + return + } + + ctx.Data["BlockingDependencies"], ctx.Data["BlockingDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking) + if ctx.Written() { + return + } + + var pinAllowed bool + if !issue.IsPinned() { + pinAllowed, err = issues_model.IsNewPinAllowed(ctx, issue.RepoID, issue.IsPull) + if err != nil { + ctx.ServerError("IsNewPinAllowed", err) + return + } + } else { + pinAllowed = true + } + + ctx.Data["Participants"] = participants + ctx.Data["NumParticipants"] = len(participants) + ctx.Data["Issue"] = issue + ctx.Data["Reference"] = issue.Ref + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(ctx.Data["Link"].(string)) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons + ctx.Data["RefEndName"] = git.RefName(issue.Ref).ShortName() + ctx.Data["NewPinAllowed"] = pinAllowed + ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0 + + prepareHiddenCommentType(ctx) + if ctx.Written() { + return + } + + // For sidebar + PrepareBranchList(ctx) + if ctx.Written() { + return + } + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + ctx.HTML(http.StatusOK, tplIssueView) +} + +// checkBlockedByIssues return canRead and notPermitted +func checkBlockedByIssues(ctx *context.Context, blockers []*issues_model.DependencyInfo) (canRead, notPermitted []*issues_model.DependencyInfo) { + repoPerms := make(map[int64]access_model.Permission) + repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission + for _, blocker := range blockers { + // Get the permissions for this repository + // If the repo ID exists in the map, return the exist permissions + // else get the permission and add it to the map + var perm access_model.Permission + existPerm, ok := repoPerms[blocker.RepoID] + if ok { + perm = existPerm + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil, nil + } + repoPerms[blocker.RepoID] = perm + } + if perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { + canRead = append(canRead, blocker) + } else { + notPermitted = append(notPermitted, blocker) + } + } + sortDependencyInfo(canRead) + sortDependencyInfo(notPermitted) + return canRead, notPermitted +} + +func sortDependencyInfo(blockers []*issues_model.DependencyInfo) { + sort.Slice(blockers, func(i, j int) bool { + if blockers[i].RepoID == blockers[j].RepoID { + return blockers[i].Issue.CreatedUnix < blockers[j].Issue.CreatedUnix + } + return blockers[i].RepoID < blockers[j].RepoID + }) +} + +// GetActionIssue will return the issue which is used in the context. +func GetActionIssue(ctx *context.Context) *issues_model.Issue { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err) + return nil + } + issue.Repo = ctx.Repo.Repository + checkIssueRights(ctx, issue) + if ctx.Written() { + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + return issue +} + +func checkIssueRights(ctx *context.Context, issue *issues_model.Issue) { + if issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) || + !issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + } +} + +func getActionIssues(ctx *context.Context) issues_model.IssueList { + commaSeparatedIssueIDs := ctx.FormString("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 := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return nil + } + // Check access rights for all issues + issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) + prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) + for _, issue := range issues { + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) + return nil + } + if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return issues +} + +// GetIssueInfo get an issue of a repository +func GetIssueInfo(ctx *context.Context) { + issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + } + return + } + + if issue.IsPull { + // Need to check if Pulls are enabled and we can read Pulls + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.Error(http.StatusNotFound) + return + } + } else { + // Need to check if Issues are enabled and we can read Issues + if !ctx.Repo.CanRead(unit.TypeIssues) { + ctx.Error(http.StatusNotFound) + return + } + } + + ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue)) +} + +// UpdateIssueTitle change issue's title +func UpdateIssueTitle(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + title := ctx.FormTrim("title") + if util.IsEmptyString(title) { + ctx.Error(http.StatusBadRequest, "Title cannot be empty or spaces") + return + } + + // Creating a CreateIssueForm with the title so that we can validate the max title length + i := forms.CreateIssueForm{ + Title: title, + } + + bindingErr := binding.RawValidate(i) + if bindingErr.Has(binding.ERR_MAX_SIZE) { + ctx.Error(http.StatusBadRequest, "Title cannot be longer than 255 characters") + return + } + + if err := issue_service.ChangeTitle(ctx, issue, ctx.Doer, title); err != nil { + ctx.ServerError("ChangeTitle", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "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.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) || issue.IsPull { + ctx.Error(http.StatusForbidden) + return + } + + ref := ctx.FormTrim("ref") + + if err := issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, ref); err != nil { + ctx.ServerError("ChangeRef", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "ref": ref, + }) +} + +// UpdateIssueContent change issue's content +func UpdateIssueContent(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, ctx.Req.FormValue("content"), ctx.FormInt("content_version")); err != nil { + if errors.Is(err, issues_model.ErrIssueAlreadyChanged) { + if issue.IsPull { + ctx.JSONError(ctx.Tr("repo.pulls.edit.already_changed")) + } else { + ctx.JSONError(ctx.Tr("repo.issues.edit.already_changed")) + } + } else { + ctx.ServerError("ChangeContent", err) + } + return + } + + // when update the request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates + if !ctx.FormBool("ignore_attachments") { + if err := updateAttachments(ctx, issue, ctx.FormStrings("files[]")); err != nil { + ctx.ServerError("UpdateAttachments", err) + return + } + } + + content, err := markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, issue.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "content": content, + "contentVersion": issue.ContentVersion, + "attachments": attachmentsHTML(ctx, issue.Attachments, issue.Content), + }) +} + +// UpdateIssueDeadline updates an issue deadline +func UpdateIssueDeadline(ctx *context.Context) { + form := web.GetForm(ctx).(*api.EditDeadlineOption) + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Error(http.StatusForbidden, "", "Not repo writer") + return + } + + var deadlineUnix timeutil.TimeStamp + var deadline time.Time + if form.Deadline != nil && !form.Deadline.IsZero() { + deadline = time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), + 23, 59, 59, 0, time.Local) + deadlineUnix = timeutil.TimeStamp(deadline.Unix()) + } + + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err.Error()) + return + } + + ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline}) +} + +// UpdateIssueMilestone change issue's milestone +func UpdateIssueMilestone(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + milestoneID := ctx.FormInt64("id") + for _, issue := range issues { + oldMilestoneID := issue.MilestoneID + if oldMilestoneID == milestoneID { + continue + } + issue.MilestoneID = milestoneID + if err := issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { + ctx.ServerError("ChangeMilestoneAssign", err) + return + } + } + + if ctx.FormBool("htmx") { + renderMilestones(ctx) + if ctx.Written() { + return + } + prepareHiddenCommentType(ctx) + if ctx.Written() { + return + } + + issue := issues[0] + var err error + if issue.MilestoneID > 0 { + issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, issue.MilestoneID) + if err != nil { + ctx.ServerError("GetMilestoneByRepoID", err) + return + } + } else { + issue.Milestone = nil + } + + comment := &issues_model.Comment{} + has, err := db.GetEngine(ctx).Where("issue_id = ? AND type = ?", issue.ID, issues_model.CommentTypeMilestone).OrderBy("id DESC").Limit(1).Get(comment) + if !has || err != nil { + ctx.ServerError("GetLatestMilestoneComment", err) + } + if err := comment.LoadMilestone(ctx); err != nil { + ctx.ServerError("LoadMilestone", err) + return + } + if err := comment.LoadPoster(ctx); err != nil { + ctx.ServerError("LoadPoster", err) + return + } + issue.Comments = issues_model.CommentList{comment} + + ctx.Data["Issue"] = issue + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.HTML(http.StatusOK, "htmx/milestone_sidebar") + } else { + ctx.JSONOK() + } +} + +// UpdateIssueAssignee change issue's or pull's assignee +func UpdateIssueAssignee(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + assigneeID := ctx.FormInt64("id") + action := ctx.FormString("action") + + for _, issue := range issues { + switch action { + case "clear": + if err := issue_service.DeleteNotPassedAssignee(ctx, issue, ctx.Doer, []*user_model.User{}); err != nil { + ctx.ServerError("ClearAssignees", err) + return + } + default: + assignee, err := user_model.GetUserByID(ctx, assigneeID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + valid, err := access_model.CanBeAssigned(ctx, assignee, issue.Repo, issue.IsPull) + if err != nil { + ctx.ServerError("canBeAssigned", err) + return + } + if !valid { + ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: assigneeID, RepoName: issue.Repo.Name}) + return + } + + _, _, err = issue_service.ToggleAssigneeWithNotify(ctx, issue, ctx.Doer, assigneeID) + if err != nil { + ctx.ServerError("ToggleAssignee", err) + return + } + } + } + ctx.JSONOK() +} + +// UpdatePullReviewRequest add or remove review request +func UpdatePullReviewRequest(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + reviewID := ctx.FormInt64("id") + action := ctx.FormString("action") + + // TODO: Not support 'clear' now + if action != "attach" && action != "detach" { + ctx.Status(http.StatusForbidden) + return + } + + for _, issue := range issues { + if err := issue.LoadRepo(ctx); 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(http.StatusForbidden) + return + } + if reviewID < 0 { + // negative reviewIDs represent team requests + if err := issue.Repo.LoadOwner(ctx); err != nil { + ctx.ServerError("issue.Repo.LoadOwner", 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(http.StatusForbidden) + return + } + + team, err := organization.GetTeamByID(ctx, -reviewID) + if err != nil { + ctx.ServerError("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(http.StatusForbidden) + return + } + + err = issue_service.IsValidTeamReviewRequest(ctx, team, ctx.Doer, action == "attach", issue) + if err != nil { + if issues_model.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(http.StatusForbidden) + return + } + ctx.ServerError("IsValidTeamReviewRequest", err) + return + } + + _, err = issue_service.TeamReviewRequest(ctx, issue, ctx.Doer, team, action == "attach") + if err != nil { + ctx.ServerError("TeamReviewRequest", err) + return + } + continue + } + + reviewer, err := user_model.GetUserByID(ctx, reviewID) + if err != nil { + if user_model.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(http.StatusForbidden) + return + } + ctx.ServerError("GetUserByID", err) + return + } + + err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, action == "attach", issue, nil) + if err != nil { + if issues_model.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(http.StatusForbidden) + return + } + ctx.ServerError("isValidReviewRequest", err) + return + } + + _, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach") + if err != nil { + if issues_model.IsErrReviewRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } + ctx.ServerError("ReviewRequest", err) + return + } + } + + ctx.JSONOK() +} + +// SearchIssues searches for issues across the repositories that the user has access to +func SearchIssues(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + log.Error("GetQueryBeforeSince: %v", err) + ctx.Error(http.StatusUnprocessableEntity, "invalid before or since") + return + } + + var isClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isClosed = optional.Some(true) + case "all": + isClosed = optional.None[bool]() + default: + isClosed = optional.Some(false) + } + + var ( + repoIDs []int64 + allPublic bool + ) + { + // find repos user can access (for issue search) + opts := &repo_model.SearchRepoOptions{ + Private: false, + AllPublic: true, + TopicOnly: false, + Collaborate: optional.None[bool](), + // This needs to be a column that is not nil in fixtures or + // MySQL will return different results when sorting by null in some cases + OrderBy: db.SearchOrderByAlphabetically, + Actor: ctx.Doer, + } + if ctx.IsSigned { + opts.Private = true + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) + if err != nil { + log.Error("GetUserByName: %v", err) + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError) + } + return + } + opts.OwnerID = owner.ID + opts.AllLimited = false + opts.AllPublic = false + opts.Collaborate = optional.Some(false) + } + if ctx.FormString("team") != "" { + if ctx.FormString("owner") == "" { + ctx.Error(http.StatusBadRequest, "Owner organisation is required for filtering on team") + return + } + team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + if err != nil { + log.Error("GetTeam: %v", err) + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest) + } else { + ctx.Error(http.StatusInternalServerError) + } + return + } + opts.TeamID = team.ID + } + + if opts.AllPublic { + allPublic = true + opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer + } + repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) + if err != nil { + log.Error("SearchRepositoryIDs: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + if len(repoIDs) == 0 { + // no repos found, don't let the indexer return all repos + repoIDs = []int64{0} + } + } + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + + isPull := optional.None[bool]() + switch ctx.FormString("type") { + case "pulls": + isPull = optional.Some(true) + case "issues": + isPull = optional.Some(false) + } + + var includedAnyLabels []int64 + { + labels := ctx.FormTrim("labels") + var includedLabelNames []string + if len(labels) > 0 { + includedLabelNames = strings.Split(labels, ",") + } + includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames) + if err != nil { + log.Error("GetLabelIDsByNames: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + } + + var includedMilestones []int64 + { + milestones := ctx.FormTrim("milestones") + var includedMilestoneNames []string + if len(milestones) > 0 { + includedMilestoneNames = strings.Split(milestones, ",") + } + includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames) + if err != nil { + log.Error("GetMilestoneIDsByNames: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + } + + projectID := optional.None[int64]() + if v := ctx.FormInt64("project"); v > 0 { + projectID = optional.Some(v) + } + + // this api is also used in UI, + // so the default limit is set to fit UI needs + limit := ctx.FormInt("limit") + if limit == 0 { + limit = setting.UI.IssuePagingNum + } else if limit > setting.API.MaxResponseItems { + limit = setting.API.MaxResponseItems + } + + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: limit, + }, + Keyword: keyword, + RepoIDs: repoIDs, + AllPublic: allPublic, + IsPull: isPull, + IsClosed: isClosed, + IncludedAnyLabelIDs: includedAnyLabels, + MilestoneIDs: includedMilestones, + ProjectID: projectID, + SortBy: issue_indexer.SortByCreatedDesc, + } + + if since != 0 { + searchOpt.UpdatedAfterUnix = optional.Some(since) + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = optional.Some(before) + } + + if ctx.IsSigned { + ctxUserID := ctx.Doer.ID + if ctx.FormBool("created") { + searchOpt.PosterID = optional.Some(ctxUserID) + } + if ctx.FormBool("assigned") { + searchOpt.AssigneeID = optional.Some(ctxUserID) + } + if ctx.FormBool("mentioned") { + searchOpt.MentionID = optional.Some(ctxUserID) + } + if ctx.FormBool("review_requested") { + searchOpt.ReviewRequestedID = optional.Some(ctxUserID) + } + if ctx.FormBool("reviewed") { + searchOpt.ReviewedID = optional.Some(ctxUserID) + } + } + + // FIXME: It's unsupported to sort by priority repo when searching by indexer, + // it's indeed an regression, but I think it is worth to support filtering by indexer first. + _ = ctx.FormInt64("priority_repo_id") + + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + log.Error("SearchIssues: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + log.Error("GetIssuesByIDs: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues)) +} + +func getUserIDForFilter(ctx *context.Context, queryName string) int64 { + userName := ctx.FormString(queryName) + if len(userName) == 0 { + return 0 + } + + user, err := user_model.GetUserByName(ctx, userName) + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("", err) + return 0 + } + + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return 0 + } + + return user.ID +} + +// ListIssues list the issues of a repository +func ListIssues(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, err.Error()) + return + } + + var isClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isClosed = optional.Some(true) + case "all": + isClosed = optional.None[bool]() + default: + isClosed = optional.Some(false) + } + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + + var labelIDs []int64 + if split := strings.Split(ctx.FormString("labels"), ","); len(split) > 0 { + labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, split) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + var mileIDs []int64 + if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 { + for i := range part { + // uses names and fall back to ids + // non existent milestones are discarded + mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i]) + if err == nil { + mileIDs = append(mileIDs, mile.ID) + continue + } + if !issues_model.IsErrMilestoneNotExist(err) { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + id, err := strconv.ParseInt(part[i], 10, 64) + if err != nil { + continue + } + mile, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, id) + if err == nil { + mileIDs = append(mileIDs, mile.ID) + continue + } + if issues_model.IsErrMilestoneNotExist(err) { + continue + } + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } + + projectID := optional.None[int64]() + if v := ctx.FormInt64("project"); v > 0 { + projectID = optional.Some(v) + } + + isPull := optional.None[bool]() + switch ctx.FormString("type") { + case "pulls": + isPull = optional.Some(true) + case "issues": + isPull = optional.Some(false) + } + + // FIXME: we should be more efficient here + createdByID := getUserIDForFilter(ctx, "created_by") + if ctx.Written() { + return + } + assignedByID := getUserIDForFilter(ctx, "assigned_by") + if ctx.Written() { + return + } + mentionedByID := getUserIDForFilter(ctx, "mentioned_by") + if ctx.Written() { + return + } + + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + ProjectID: projectID, + SortBy: issue_indexer.SortByCreatedDesc, + } + if since != 0 { + searchOpt.UpdatedAfterUnix = optional.Some(since) + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = optional.Some(before) + } + if len(labelIDs) == 1 && labelIDs[0] == 0 { + searchOpt.NoLabelOnly = true + } else { + for _, labelID := range labelIDs { + if labelID > 0 { + searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) + } else { + searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) + } + } + } + + if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID { + searchOpt.MilestoneIDs = []int64{0} + } else { + searchOpt.MilestoneIDs = mileIDs + } + + if createdByID > 0 { + searchOpt.PosterID = optional.Some(createdByID) + } + if assignedByID > 0 { + searchOpt.AssigneeID = optional.Some(assignedByID) + } + if mentionedByID > 0 { + searchOpt.MentionID = optional.Some(mentionedByID) + } + + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssues", err.Error()) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues)) +} + +func BatchDeleteIssues(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + for _, issue := range issues { + if err := issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { + ctx.ServerError("DeleteIssue", err) + return + } + } + ctx.JSONOK() +} + +// UpdateIssueStatus change issue's status +func UpdateIssueStatus(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + var isClosed bool + switch action := ctx.FormString("action"); action { + case "open": + isClosed = false + case "close": + isClosed = true + default: + log.Warn("Unrecognized action: %s", action) + } + + if _, err := issues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + if err := issues.LoadPullRequests(ctx); err != nil { + ctx.ServerError("LoadPullRequests", err) + return + } + + for _, issue := range issues { + if issue.IsPull && issue.PullRequest.HasMerged { + continue + } + if issue.IsClosed != isClosed { + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.JSON(http.StatusPreconditionFailed, map[string]any{ + "error": ctx.Tr("repo.issues.dependency.issue_batch_close_blocked", issue.Index), + }) + return + } + ctx.ServerError("ChangeStatus", err) + return + } + } + } + ctx.JSONOK() +} + +// 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.Doer.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.Doer, + 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.Doer.IsAdmin { + ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked")) + return + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + var comment *issues_model.Comment + defer func() { + // Check if issue admin/poster changes the status of issue. + if (ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) || (ctx.IsSigned && issue.IsPoster(ctx.Doer.ID))) && + (form.Status == "reopen" || form.Status == "close") && + !(issue.IsPull && issue.PullRequest.HasMerged) { + // Duplication and conflict check should apply to reopen pull request. + var pr *issues_model.PullRequest + + if form.Status == "reopen" && issue.IsPull { + pull := issue.PullRequest + var err error + pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow) + if err != nil { + if !issues_model.IsErrPullRequestNotExist(err) { + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + return + } + } + + // Regenerate patch and test conflict. + if pr == nil { + issue.PullRequest.HeadCommitID = "" + pull_service.AddToTaskQueue(ctx, issue.PullRequest) + } + + // check whether the ref of PR <refs/pulls/pr_index/head> in base repo is consistent with the head commit of head branch in the head repo + // get head commit of PR + if pull.Flow == issues_model.PullRequestFlowGithub { + if err := pull.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("Unable to load base repo", err) + return + } + if err := pull.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("Unable to load head repo", err) + return + } + + // Check if the base branch of the pull request still exists. + if ok := git.IsBranchExist(ctx, pull.BaseRepo.RepoPath(), pull.BaseBranch); !ok { + ctx.JSONError(ctx.Tr("repo.pulls.reopen_failed.base_branch")) + return + } + + // Check if the head branch of the pull request still exists. + if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.HeadBranch); !ok { + ctx.JSONError(ctx.Tr("repo.pulls.reopen_failed.head_branch")) + return + } + + prHeadRef := pull.GetGitRefName() + prHeadCommitID, err := git.GetFullCommitID(ctx, pull.BaseRepo.RepoPath(), prHeadRef) + if err != nil { + ctx.ServerError("Get head commit Id of pr fail", err) + return + } + + headBranchRef := pull.GetGitHeadBranchRefName() + headBranchCommitID, err := git.GetFullCommitID(ctx, pull.HeadRepo.RepoPath(), headBranchRef) + if err != nil { + ctx.ServerError("Get head commit Id of head branch fail", err) + return + } + + err = pull.LoadIssue(ctx) + if err != nil { + ctx.ServerError("load the issue of pull request error", err) + return + } + + if prHeadCommitID != headBranchCommitID { + // force push to base repo + err := git.Push(ctx, pull.HeadRepo.RepoPath(), git.PushOptions{ + Remote: pull.BaseRepo.RepoPath(), + Branch: pull.HeadBranch + ":" + prHeadRef, + Force: true, + Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo), + }) + if err != nil { + ctx.ServerError("force push error", err) + return + } + } + } + } + + 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(ctx, issue, ctx.Doer, "", isClosed); err != nil { + log.Error("ChangeStatus: %v", err) + + if issues_model.IsErrDependenciesLeft(err) { + if issue.IsPull { + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + } else { + ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked")) + } + return + } + } else { + if err := stopTimerIfAvailable(ctx, ctx.Doer, 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.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag())) + } else { + ctx.JSONRedirect(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 := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) + if err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_by_user")) + } else { + 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 := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + oldContent := comment.Content + newContent := ctx.FormString("content") + contentVersion := ctx.FormInt("content_version") + + comment.Content = newContent + if err = issue_service.UpdateComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil { + if errors.Is(err, issues_model.ErrCommentAlreadyChanged) { + ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed")) + } else { + ctx.ServerError("UpdateComment", err) + } + return + } + + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + + // when the update request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates + if !ctx.FormBool("ignore_attachments") { + if err := updateAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil { + ctx.ServerError("UpdateAttachments", err) + return + } + } + + content, err := markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "content": content, + "contentVersion": comment.ContentVersion, + "attachments": attachmentsHTML(ctx, comment.Attachments, comment.Content), + }) +} + +// DeleteComment delete comment of issue +func DeleteComment(ctx *context.Context) { + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } else if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + if err = issue_service.DeleteComment(ctx, ctx.Doer, comment); err != nil { + ctx.ServerError("DeleteComment", err) + return + } + + ctx.Status(http.StatusOK) +} + +// 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.Doer.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.Doer, + 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 := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content) + if err != nil { + if issues_model.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } + log.Info("CreateIssueReaction: %s", err) + break + } + + log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) + case "unreact": + if err := issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content); err != nil { + ctx.ServerError("DeleteIssueReaction", err) + return + } + + 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 + } + + // Reload new reactions + issue.Reactions = nil + if err := issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("ChangeIssueReaction.LoadAttributes", err) + return + } + + if len(issue.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]any{ + "empty": true, + "html": "", + }) + return + } + + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ + "ctxData": 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]any{ + "html": html, + }) +} + +// ChangeCommentReaction create a reaction for comment +func ChangeCommentReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.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.Doer, + comment.Issue.PosterID, + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + switch ctx.Params(":action") { + case "react": + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Content) + if err != nil { + if issues_model.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } + log.Info("CreateCommentReaction: %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 := issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content); err != nil { + ctx.ServerError("DeleteCommentReaction", err) + return + } + + 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 + } + + // Reload new reactions + comment.Reactions = nil + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + ctx.ServerError("ChangeCommentReaction.LoadReactions", err) + return + } + + if len(comment.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]any{ + "empty": true, + "html": "", + }) + return + } + + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ + "ctxData": 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]any{ + "html": html, + }) +} + +func addParticipant(poster *user_model.User, participants []*user_model.User) []*user_model.User { + for _, part := range participants { + if poster.ID == part.ID { + return participants + } + } + return append(participants, poster) +} + +func filterXRefComments(ctx *context.Context, issue *issues_model.Issue) error { + // Remove comments that the user has no permissions to see + for i := 0; i < len(issue.Comments); { + c := issue.Comments[i] + if issues_model.CommentTypeIsRef(c.Type) && c.RefRepoID != issue.RepoID && c.RefRepoID != 0 { + var err error + // Set RefRepo for description in template + c.RefRepo, err = repo_model.GetRepositoryByID(ctx, c.RefRepoID) + if err != nil { + return err + } + perm, err := access_model.GetUserRepoPermission(ctx, c.RefRepo, ctx.Doer) + 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) + if ctx.Written() { + return + } + attachments := make([]*api.Attachment, len(issue.Attachments)) + for i := 0; i < len(issue.Attachments); i++ { + attachments[i] = convert.ToAttachment(ctx.Repo.Repository, issue.Attachments[i]) + } + ctx.JSON(http.StatusOK, attachments) +} + +// GetCommentAttachments returns attachments for the comment +func GetCommentAttachments(ctx *context.Context) { + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.Repo.Permission.CanReadIssuesOrPulls(comment.Issue.IsPull) { + ctx.NotFound("CanReadIssuesOrPulls", issues_model.ErrCommentNotExist{}) + return + } + + if !comment.Type.HasAttachmentSupport() { + ctx.ServerError("GetCommentAttachments", fmt.Errorf("comment type %v does not support attachments", comment.Type)) + return + } + + attachments := make([]*api.Attachment, 0) + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + for i := 0; i < len(comment.Attachments); i++ { + attachments = append(attachments, convert.ToAttachment(ctx.Repo.Repository, comment.Attachments[i])) + } + ctx.JSON(http.StatusOK, attachments) +} + +func updateAttachments(ctx *context.Context, item any, files []string) error { + var attachments []*repo_model.Attachment + switch content := item.(type) { + case *issues_model.Issue: + attachments = content.Attachments + case *issues_model.Comment: + attachments = content.Attachments + default: + return fmt.Errorf("unknown Type: %T", content) + } + for i := 0; i < len(attachments); i++ { + if util.SliceContainsString(files, attachments[i].UUID) { + continue + } + if err := repo_model.DeleteAttachment(ctx, attachments[i], true); err != nil { + return err + } + } + var err error + if len(files) > 0 { + switch content := item.(type) { + case *issues_model.Issue: + err = issues_model.UpdateIssueAttachments(ctx, content.ID, files) + case *issues_model.Comment: + err = content.UpdateAttachments(ctx, files) + default: + return fmt.Errorf("unknown Type: %T", content) + } + if err != nil { + return err + } + } + switch content := item.(type) { + case *issues_model.Issue: + content.Attachments, err = repo_model.GetAttachmentsByIssueID(ctx, content.ID) + case *issues_model.Comment: + content.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, content.ID) + default: + return fmt.Errorf("unknown Type: %T", content) + } + return err +} + +func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment, content string) template.HTML { + attachHTML, err := ctx.RenderToHTML(tplAttachment, map[string]any{ + "ctxData": 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 *issues_model.Issue) { + var prev, cur *issues_model.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 != issues_model.CommentTypeLabel || + (prev != nil && prev.PosterID != cur.PosterID) || + (prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) { + if cur.Type == issues_model.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 == issues_model.CommentTypeLabel { // we can combine them only prev is a label comment + if cur.Content != "1" { + // remove labels from the AddedLabels list if the label that was removed is already + // in this list, and if it's not in this list, add the label to RemovedLabels + addedAndRemoved := false + for i, label := range prev.AddedLabels { + if cur.Label.ID == label.ID { + prev.AddedLabels = append(prev.AddedLabels[:i], prev.AddedLabels[i+1:]...) + addedAndRemoved = true + break + } + } + if !addedAndRemoved { + prev.RemovedLabels = append(prev.RemovedLabels, cur.Label) + } + } else { + // remove labels from the RemovedLabels list if the label that was added is already + // in this list, and if it's not in this list, add the label to AddedLabels + removedAndAdded := false + for i, label := range prev.RemovedLabels { + if cur.Label.ID == label.ID { + prev.RemovedLabels = append(prev.RemovedLabels[:i], prev.RemovedLabels[i+1:]...) + removedAndAdded = true + break + } + } + if !removedAndAdded { + 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.Doer == nil || !ctx.Repo.Owner.IsOrganization() { + return + } + + var isAdmin bool + var err error + var teams []*organization.Team + org := organization.OrgFromUser(ctx.Repo.Owner) + // Admin has super access. + if ctx.Doer.IsAdmin { + isAdmin = true + } else { + isAdmin, err = org.IsOwnedBy(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("IsOwnedBy", err) + return + } + } + + if isAdmin { + teams, err = org.LoadTeams(ctx) + if err != nil { + ctx.ServerError("LoadTeams", err) + return + } + } else { + teams, err = org.GetUserTeams(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserTeams", err) + return + } + } + + ctx.Data["MentionableTeams"] = teams + ctx.Data["MentionableTeamsOrg"] = ctx.Repo.Owner.Name + ctx.Data["MentionableTeamsOrgAvatar"] = ctx.Repo.Owner.AvatarLink(ctx) +} + +type userSearchInfo struct { + UserID int64 `json:"user_id"` + UserName string `json:"username"` + AvatarLink string `json:"avatar_link"` + FullName string `json:"full_name"` +} + +type userSearchResponse struct { + Results []*userSearchInfo `json:"results"` +} + +// IssuePosters get posters for current repo's issues/pull requests +func IssuePosters(ctx *context.Context) { + issuePosters(ctx, false) +} + +func PullPosters(ctx *context.Context) { + issuePosters(ctx, true) +} + +func issuePosters(ctx *context.Context, isPullList bool) { + repo := ctx.Repo.Repository + search := strings.TrimSpace(ctx.FormString("q")) + posters, err := repo_model.GetIssuePostersWithSearch(ctx, repo, isPullList, search, setting.UI.DefaultShowFullName) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + + if search == "" && ctx.Doer != nil { + // the returned posters slice only contains limited number of users, + // to make the current user (doer) can quickly filter their own issues, always add doer to the posters slice + if !slices.ContainsFunc(posters, func(user *user_model.User) bool { return user.ID == ctx.Doer.ID }) { + posters = append(posters, ctx.Doer) + } + } + + posters = MakeSelfOnTop(ctx.Doer, posters) + + resp := &userSearchResponse{} + resp.Results = make([]*userSearchInfo, len(posters)) + for i, user := range posters { + resp.Results[i] = &userSearchInfo{UserID: user.ID, UserName: user.Name, AvatarLink: user.AvatarLink(ctx)} + if setting.UI.DefaultShowFullName { + resp.Results[i].FullName = user.FullName + } + } + ctx.JSON(http.StatusOK, resp) +} diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go new file mode 100644 index 0000000..16b250a --- /dev/null +++ b/routers/web/repo/issue_content_history.go @@ -0,0 +1,237 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "html" + "net/http" + "strings" + + "code.gitea.io/gitea/models/avatars" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/context" + + "github.com/sergi/go-diff/diffmatchpatch" +) + +// GetContentHistoryOverview get overview +func GetContentHistoryOverview(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + editedHistoryCountMap, _ := issues_model.QueryIssueContentHistoryEditedCountMap(ctx, issue.ID) + ctx.JSON(http.StatusOK, map[string]any{ + "i18n": map[string]any{ + "textEdited": ctx.Tr("repo.issues.content_history.edited"), + "textDeleteFromHistory": ctx.Tr("repo.issues.content_history.delete_from_history"), + "textDeleteFromHistoryConfirm": ctx.Tr("repo.issues.content_history.delete_from_history_confirm"), + "textOptions": ctx.Tr("repo.issues.content_history.options"), + }, + "editedHistoryCountMap": editedHistoryCountMap, + }) +} + +// GetContentHistoryList get list +func GetContentHistoryList(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + commentID := ctx.FormInt64("comment_id") + items, _ := issues_model.FetchIssueContentHistoryList(ctx, issue.ID, commentID) + + // render history list to HTML for frontend dropdown items: (name, value) + // name is HTML of "avatar + userName + userAction + timeSince" + // value is historyId + var results []map[string]any + for _, item := range items { + var actionText string + if item.IsDeleted { + actionTextDeleted := ctx.Locale.TrString("repo.issues.content_history.deleted") + actionText = "<i data-history-is-deleted='1'>" + actionTextDeleted + "</i>" + } else if item.IsFirstCreated { + actionText = ctx.Locale.TrString("repo.issues.content_history.created") + } else { + actionText = ctx.Locale.TrString("repo.issues.content_history.edited") + } + + username := item.UserName + if setting.UI.DefaultShowFullName && strings.TrimSpace(item.UserFullName) != "" { + username = strings.TrimSpace(item.UserFullName) + } + + src := html.EscapeString(item.UserAvatarLink) + class := avatars.DefaultAvatarClass + " tw-mr-2" + name := html.EscapeString(username) + avatarHTML := string(templates.AvatarHTML(src, 28, class, username)) + timeSinceText := string(timeutil.TimeSinceUnix(item.EditedUnix, ctx.Locale)) + + results = append(results, map[string]any{ + "name": avatarHTML + "<strong>" + name + "</strong> " + actionText + " " + timeSinceText, + "value": item.HistoryID, + }) + } + + ctx.JSON(http.StatusOK, map[string]any{ + "results": results, + }) +} + +// canSoftDeleteContentHistory checks whether current user can soft-delete a history revision +// Admins or owners can always delete history revisions. Normal users can only delete own history revisions. +func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue, comment *issues_model.Comment, + history *issues_model.ContentHistory, +) (canSoftDelete bool) { + // CanWrite means the doer can manage the issue/PR list + if ctx.Repo.IsOwner() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + canSoftDelete = true + } else if ctx.Doer == nil { + canSoftDelete = false + } else { + // for read-only users, they could still post issues or comments, + // they should be able to delete the history related to their own issue/comment, a case is: + // 1. the user posts some sensitive data + // 2. then the repo owner edits the post but didn't remove the sensitive data + // 3. the poster wants to delete the edited history revision + if comment == nil { + // the issue poster or the history poster can soft-delete + canSoftDelete = ctx.Doer.ID == issue.PosterID || ctx.Doer.ID == history.PosterID + canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) + } else { + // the comment poster or the history poster can soft-delete + canSoftDelete = ctx.Doer.ID == comment.PosterID || ctx.Doer.ID == history.PosterID + canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) + canSoftDelete = canSoftDelete && (history.CommentID == comment.ID) + } + } + return canSoftDelete +} + +// GetContentHistoryDetail get detail +func GetContentHistoryDetail(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + historyID := ctx.FormInt64("history_id") + history, prevHistory, err := issues_model.GetIssueContentHistoryAndPrev(ctx, issue.ID, historyID) + if err != nil { + ctx.JSON(http.StatusNotFound, map[string]any{ + "message": "Can not find the content history", + }) + return + } + + // get the related comment if this history revision is for a comment, otherwise the history revision is for an issue. + var comment *issues_model.Comment + if history.CommentID != 0 { + var err error + if comment, err = issues_model.GetCommentByID(ctx, history.CommentID); err != nil { + log.Error("can not get comment for issue content history %v. err=%v", historyID, err) + return + } + } + + // get the previous history revision (if exists) + var prevHistoryID int64 + var prevHistoryContentText string + if prevHistory != nil { + prevHistoryID = prevHistory.ID + prevHistoryContentText = prevHistory.ContentText + } + + // compare the current history revision with the previous one + dmp := diffmatchpatch.New() + // `checklines=false` makes better diff result + diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, false) + diff = dmp.DiffCleanupSemantic(diff) + diff = dmp.DiffCleanupEfficiency(diff) + + // use chroma to render the diff html + diffHTMLBuf := bytes.Buffer{} + diffHTMLBuf.WriteString("<pre class='chroma'>") + for _, it := range diff { + if it.Type == diffmatchpatch.DiffInsert { + diffHTMLBuf.WriteString("<span class='gi'>") + diffHTMLBuf.WriteString(html.EscapeString(it.Text)) + diffHTMLBuf.WriteString("</span>") + } else if it.Type == diffmatchpatch.DiffDelete { + diffHTMLBuf.WriteString("<span class='gd'>") + diffHTMLBuf.WriteString(html.EscapeString(it.Text)) + diffHTMLBuf.WriteString("</span>") + } else { + diffHTMLBuf.WriteString(html.EscapeString(it.Text)) + } + } + diffHTMLBuf.WriteString("</pre>") + + ctx.JSON(http.StatusOK, map[string]any{ + "canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history), + "historyId": historyID, + "prevHistoryId": prevHistoryID, + "diffHtml": diffHTMLBuf.String(), + }) +} + +// SoftDeleteContentHistory soft delete +func SoftDeleteContentHistory(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + commentID := ctx.FormInt64("comment_id") + historyID := ctx.FormInt64("history_id") + + var comment *issues_model.Comment + var history *issues_model.ContentHistory + var err error + + if history, err = issues_model.GetIssueContentHistoryByID(ctx, historyID); err != nil { + log.Error("can not get issue content history %v. err=%v", historyID, err) + return + } + if history.IssueID != issue.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + if commentID != 0 { + if history.CommentID != commentID { + ctx.NotFound("CompareCommentID", issues_model.ErrCommentNotExist{}) + return + } + + if comment, err = issues_model.GetCommentByID(ctx, commentID); err != nil { + log.Error("can not get comment for issue content history %v. err=%v", historyID, err) + return + } + if comment.IssueID != issue.ID { + ctx.NotFound("CompareIssueID", issues_model.ErrCommentNotExist{}) + return + } + } + + canSoftDelete := canSoftDeleteContentHistory(ctx, issue, comment, history) + if !canSoftDelete { + ctx.JSON(http.StatusForbidden, map[string]any{ + "message": "Can not delete the content history", + }) + return + } + + err = issues_model.SoftDeleteIssueContentHistory(ctx, historyID) + log.Debug("soft delete issue content history. issue=%d, comment=%d, history=%d", issue.ID, commentID, historyID) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": err == nil, + }) +} diff --git a/routers/web/repo/issue_dependency.go b/routers/web/repo/issue_dependency.go new file mode 100644 index 0000000..66b3868 --- /dev/null +++ b/routers/web/repo/issue_dependency.go @@ -0,0 +1,144 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// AddDependency adds new dependencies +func AddDependency(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := issues_model.GetIssueByIndex(ctx, 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, ctx.Doer, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + return + } + + depID := ctx.FormInt64("newDependency") + + if err = issue.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Redirect + defer ctx.Redirect(issue.Link()) + + // Dependency + dep, err := issues_model.GetIssueByID(ctx, 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 { + if !setting.Service.AllowCrossRepositoryDependencies { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) + return + } + if err := dep.LoadRepo(ctx); err != nil { + ctx.ServerError("loadRepo", err) + return + } + // Can ctx.Doer read issues in the dep repo? + depRepoPerm, err := access_model.GetUserRepoPermission(ctx, dep.Repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !depRepoPerm.CanReadIssuesOrPulls(dep.IsPull) { + // you can't see this dependency + 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 = issues_model.CreateIssueDependency(ctx, ctx.Doer, issue, dep) + if err != nil { + if issues_model.IsErrDependencyExists(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_exists")) + return + } else if issues_model.IsErrCircularDependency(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_cannot_create_circular")) + return + } + ctx.ServerError("CreateOrUpdateIssueDependency", err) + return + } +} + +// RemoveDependency removes the dependency +func RemoveDependency(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := issues_model.GetIssueByIndex(ctx, 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, ctx.Doer, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + return + } + + depID := ctx.FormInt64("removeDependencyID") + + if err = issue.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Dependency Type + depTypeStr := ctx.Req.PostFormValue("dependencyType") + + var depType issues_model.DependencyType + + switch depTypeStr { + case "blockedBy": + depType = issues_model.DependencyTypeBlockedBy + case "blocking": + depType = issues_model.DependencyTypeBlocking + default: + ctx.Error(http.StatusBadRequest, "GetDependecyType") + return + } + + // Dependency + dep, err := issues_model.GetIssueByID(ctx, depID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if err = issues_model.RemoveIssueDependency(ctx, ctx.Doer, issue, dep, depType); err != nil { + if issues_model.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.Link()) +} diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go new file mode 100644 index 0000000..81bee4d --- /dev/null +++ b/routers/web/repo/issue_label.go @@ -0,0 +1,229 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/label" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "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["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + 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 := repo_module.InitializeLabels(ctx, ctx.Repo.Repository.ID, form.TemplateName, false); err != nil { + if label.IsErrTemplateLoad(err) { + originalErr := err.(label.ErrTemplateLoad).OriginalError + ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) + ctx.Redirect(ctx.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 := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("RetrieveLabels.GetLabels", err) + return + } + + for _, l := range labels { + l.CalOpenIssues() + } + + ctx.Data["Labels"] = labels + + if ctx.Repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + for _, l := range orgLabels { + l.CalOpenOrgIssues(ctx, ctx.Repo.Repository.ID, l.ID) + } + ctx.Data["OrgLabels"] = orgLabels + + org, err := organization.GetOrgByName(ctx, ctx.Repo.Owner.LowerName) + if err != nil { + ctx.ServerError("GetOrgByName", err) + return + } + if ctx.Doer != nil { + ctx.Org.IsOwner, err = org.IsOwnedBy(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("org.IsOwnedBy", err) + return + } + ctx.Org.OrgLink = org.AsUser().OrganisationLink() + ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner + ctx.Data["OrganizationLink"] = ctx.Org.OrgLink + } + } + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SortType"] = ctx.FormString("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 := &issues_model.Label{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Exclusive: form.Exclusive, + Description: form.Description, + Color: form.Color, + } + if err := issues_model.NewLabel(ctx, 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 := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, form.ID) + if err != nil { + switch { + case issues_model.IsErrRepoLabelNotExist(err): + ctx.Error(http.StatusNotFound) + default: + ctx.ServerError("UpdateLabel", err) + } + return + } + l.Name = form.Title + l.Exclusive = form.Exclusive + l.Description = form.Description + l.Color = form.Color + + l.SetArchived(form.IsArchived) + if err := issues_model.UpdateLabel(ctx, l); err != nil { + ctx.ServerError("UpdateLabel", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/labels") +} + +// DeleteLabel delete a label +func DeleteLabel(ctx *context.Context) { + if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteLabel: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/labels") +} + +// UpdateIssueLabel change issue's labels +func UpdateIssueLabel(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + switch action := ctx.FormString("action"); action { + case "clear": + for _, issue := range issues { + if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil { + ctx.ServerError("ClearLabels", err) + return + } + } + case "attach", "detach", "toggle", "toggle-alt": + label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id")) + if err != nil { + if issues_model.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" + if label.ExclusiveScope() == "" { + for _, issue := range issues { + if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) { + action = "detach" + break + } + } + } + } else if action == "toggle-alt" { + // always detach with alt key pressed, to be able to remove + // scoped labels + action = "detach" + } + + if action == "attach" { + for _, issue := range issues { + if err = issue_service.AddLabel(ctx, issue, ctx.Doer, label); err != nil { + ctx.ServerError("AddLabel", err) + return + } + } + } else { + for _, issue := range issues { + if err = issue_service.RemoveLabel(ctx, issue, ctx.Doer, label); err != nil { + ctx.ServerError("RemoveLabel", err) + return + } + } + } + default: + log.Warn("Unrecognized action: %s", action) + ctx.Error(http.StatusInternalServerError) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/repo/issue_label_test.go b/routers/web/repo/issue_label_test.go new file mode 100644 index 0000000..2b4915e --- /dev/null +++ b/routers/web/repo/issue_label_test.go @@ -0,0 +1,173 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strconv" + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func 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) { + unittest.PrepareTestEnv(t) + require.NoError(t, repository.LoadRepoConfig()) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/initialize") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 2) + web.SetForm(ctx, &forms.InitializeLabelsForm{TemplateName: "Default"}) + InitializeLabels(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ + RepoID: 2, + Name: "enhancement", + Color: "#84b6eb", + }) + assert.Equal(t, "/user2/repo2/labels", test.RedirectURL(ctx.Resp)) +} + +func TestRetrieveLabels(t *testing.T) { + unittest.PrepareTestEnv(t) + for _, testCase := range []struct { + RepoID int64 + Sort string + ExpectedLabelIDs []int64 + }{ + {1, "", []int64{1, 2}}, + {1, "leastissues", []int64{2, 1}}, + {2, "", []int64{}}, + } { + ctx, _ := contexttest.MockContext(t, "user/repo/issues") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, testCase.RepoID) + ctx.Req.Form.Set("sort", testCase.Sort) + RetrieveLabels(ctx) + assert.False(t, ctx.Written()) + labels, ok := ctx.Data["Labels"].([]*issues_model.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) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + Title: "newlabel", + Color: "#abcdef", + }) + NewLabel(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ + Name: "newlabel", + Color: "#abcdef", + }) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) +} + +func TestUpdateLabel(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + ID: 2, + Title: "newnameforlabel", + Color: "#abcdef", + IsArchived: true, + }) + UpdateLabel(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ + ID: 2, + Name: "newnameforlabel", + Color: "#abcdef", + }) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) +} + +func TestDeleteLabel(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/delete") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + ctx.Req.Form.Set("id", "2") + DeleteLabel(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + unittest.AssertNotExistsBean(t, &issues_model.Label{ID: 2}) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{LabelID: 2}) + assert.EqualValues(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg) +} + +func TestUpdateIssueLabel_Clear(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.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()) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: 1}) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: 3}) + unittest.CheckConsistencyFor(t, &issues_model.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}, + } { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.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 { + unittest.AssertExistsIf(t, testCase.ExpectedAdd, &issues_model.IssueLabel{ + IssueID: issueID, + LabelID: testCase.LabelID, + }) + } + unittest.CheckConsistencyFor(t, &issues_model.Label{}) + } +} diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go new file mode 100644 index 0000000..1d5fc8a --- /dev/null +++ b/routers/web/repo/issue_lock.go @@ -0,0 +1,65 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "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.JSONError(ctx.Tr("repo.issues.lock_duplicate")) + return + } + + if !form.HasValidReason() { + ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason")) + return + } + + if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{ + Doer: ctx.Doer, + Issue: issue, + Reason: form.Reason, + }); err != nil { + ctx.ServerError("LockIssue", err) + return + } + + ctx.JSONRedirect(issue.Link()) +} + +// UnlockIssue unlocks a previously locked issue. +func UnlockIssue(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !issue.IsLocked { + ctx.JSONError(ctx.Tr("repo.issues.unlock_error")) + return + } + + if err := issues_model.UnlockIssue(ctx, &issues_model.IssueLockOptions{ + Doer: ctx.Doer, + Issue: issue, + }); err != nil { + ctx.ServerError("UnlockIssue", err) + return + } + + ctx.JSONRedirect(issue.Link()) +} diff --git a/routers/web/repo/issue_pin.go b/routers/web/repo/issue_pin.go new file mode 100644 index 0000000..365c812 --- /dev/null +++ b/routers/web/repo/issue_pin.go @@ -0,0 +1,107 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +// IssuePinOrUnpin pin or unpin a Issue +func IssuePinOrUnpin(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + // If we don't do this, it will crash when trying to add the pin event to the comment history + err := issue.LoadRepo(ctx) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + err = issue.PinOrUnpin(ctx, ctx.Doer) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + ctx.JSONRedirect(issue.Link()) +} + +// IssueUnpin unpins a Issue +func IssueUnpin(ctx *context.Context) { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + // If we don't do this, it will crash when trying to add the pin event to the comment history + err = issue.LoadRepo(ctx) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + err = issue.Unpin(ctx, ctx.Doer) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + ctx.Status(http.StatusNoContent) +} + +// IssuePinMove moves a pinned Issue +func IssuePinMove(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.") + return + } + + type movePinIssueForm struct { + ID int64 `json:"id"` + Position int `json:"position"` + } + + form := &movePinIssueForm{} + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + issue, err := issues_model.GetIssueByID(ctx, form.ID) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + log.Error("Issue does not belong to this repository") + return + } + + err = issue.MovePin(ctx, form.Position) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go new file mode 100644 index 0000000..70d42b2 --- /dev/null +++ b/routers/web/repo/issue_stopwatch.go @@ -0,0 +1,113 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/services/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 !issues_model.StopwatchExists(c, c.Doer.ID, issue.ID) { + showSuccessMessage = true + } + + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + + if err := issues_model.CreateOrStopIssueStopwatch(c, c.Doer, issue); err != nil { + c.ServerError("CreateOrStopIssueStopwatch", err) + return + } + + if showSuccessMessage { + c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) + } + + url := issue.Link() + 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(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + + if err := issues_model.CancelStopwatch(c, c.Doer, issue); err != nil { + c.ServerError("CancelStopwatch", err) + return + } + + stopwatches, err := issues_model.GetUserStopwatches(c, c.Doer.ID, db.ListOptions{}) + if err != nil { + c.ServerError("GetUserStopwatches", err) + return + } + if len(stopwatches) == 0 { + eventsource.GetManager().SendMessage(c.Doer.ID, &eventsource.Event{ + Name: "stopwatches", + Data: "{}", + }) + } + + url := issue.Link() + c.Redirect(url, http.StatusSeeOther) +} + +// GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context +func GetActiveStopwatch(ctx *context.Context) { + if strings.HasPrefix(ctx.Req.URL.Path, "/api") { + return + } + + if !ctx.IsSigned { + return + } + + _, sw, issue, err := issues_model.HasUserStopwatch(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("HasUserStopwatch", err) + return + } + + if sw == nil || sw.ID == 0 { + return + } + + ctx.Data["ActiveStopwatch"] = StopwatchTmplInfo{ + issue.Link(), + 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 { + IssueLink string + 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 0000000..f1d0aac --- /dev/null +++ b/routers/web/repo/issue_test.go @@ -0,0 +1,375 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + + "github.com/stretchr/testify/assert" +) + +func TestCombineLabelComments(t *testing.T) { + kases := []struct { + name string + beforeCombined []*issues_model.Comment + afterCombined []*issues_model.Comment + }{ + { + name: "kase 1", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*issues_model.Label{}, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 2", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 70, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + CreatedUnix: 70, + RemovedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 3", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 2, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 2, + Content: "", + CreatedUnix: 0, + RemovedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 4", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/backport", + }, + CreatedUnix: 10, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 10, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + { + Name: "kind/backport", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + }, + }, + { + name: "kase 5", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 2, + Content: "testtest", + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 2, + Content: "testtest", + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + RemovedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 6", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "reviewed/confirmed", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/feature", + }, + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + AddedLabels: []*issues_model.Label{ + { + Name: "reviewed/confirmed", + }, + { + Name: "kind/feature", + }, + }, + CreatedUnix: 0, + }, + }, + }, + } + + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + issue := issues_model.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 0000000..241e434 --- /dev/null +++ b/routers/web/repo/issue_timetrack.go @@ -0,0 +1,87 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "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(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + url := issue.Link() + + 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 := issues_model.AddTime(c, c.Doer, 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(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + + t, err := issues_model.GetTrackedTimeByID(c, c.ParamsInt64(":timeid")) + if err != nil { + if db.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.Doer.ID != t.UserID) { + c.Error(http.StatusForbidden, "not allowed") + return + } + + if err = issues_model.DeleteTime(c, t); err != nil { + c.ServerError("DeleteTime", err) + return + } + + c.Flash.Success(c.Tr("repo.issues.del_time_history", util.SecToTime(t.Time))) + c.Redirect(issue.Link()) +} diff --git a/routers/web/repo/issue_watch.go b/routers/web/repo/issue_watch.go new file mode 100644 index 0000000..5cff9f4 --- /dev/null +++ b/routers/web/repo/issue_watch.go @@ -0,0 +1,63 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strconv" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +const ( + tplWatching base.TplName = "repo/issue/view_content/sidebar/watching" +) + +// IssueWatch sets issue watching +func IssueWatch(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.Doer.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.Doer, + 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.PostFormValue("watch")) + if err != nil { + ctx.ServerError("watch is not bool", err) + return + } + + if err := issues_model.CreateOrUpdateIssueWatch(ctx, ctx.Doer.ID, issue.ID, watch); err != nil { + ctx.ServerError("CreateOrUpdateIssueWatch", err) + return + } + + ctx.Data["Issue"] = issue + ctx.Data["IssueWatch"] = &issues_model.IssueWatch{IsWatching: watch} + ctx.HTML(http.StatusOK, tplWatching) +} diff --git a/routers/web/repo/main_test.go b/routers/web/repo/main_test.go new file mode 100644 index 0000000..6e469cf --- /dev/null +++ b/routers/web/repo/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/repo/middlewares.go b/routers/web/repo/middlewares.go new file mode 100644 index 0000000..ddda9f3 --- /dev/null +++ b/routers/web/repo/middlewares.go @@ -0,0 +1,120 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "strconv" + + system_model "code.gitea.io/gitea/models/system" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +// SetEditorconfigIfExists set editor config as render variable +func SetEditorconfigIfExists(ctx *context.Context) { + if ctx.Repo.Repository.IsEmpty { + return + } + + ec, _, err := ctx.Repo.GetEditorconfig() + + if err != nil && !git.IsErrNotExist(err) { + description := fmt.Sprintf("Error while getting .editorconfig file: %v", err) + if err := system_model.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.FormString("style") + + if !ctx.IsSigned { + ctx.Data["IsSplitStyle"] = queryStyle == "split" + return + } + + var ( + userStyle = ctx.Doer.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" + + opts := &user_service.UpdateOptions{ + DiffViewStyle: optional.Some(style), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + } +} + +// SetWhitespaceBehavior set whitespace behavior as render variable +func SetWhitespaceBehavior(ctx *context.Context) { + const defaultWhitespaceBehavior = "show-all" + whitespaceBehavior := ctx.FormString("whitespace") + switch whitespaceBehavior { + case "", "ignore-all", "ignore-eol", "ignore-change": + break + default: + whitespaceBehavior = defaultWhitespaceBehavior + } + if ctx.IsSigned { + userWhitespaceBehavior, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, defaultWhitespaceBehavior) + if err == nil { + if whitespaceBehavior == "" { + whitespaceBehavior = userWhitespaceBehavior + } else if whitespaceBehavior != userWhitespaceBehavior { + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, whitespaceBehavior) + } + } // else: we can ignore the error safely + } + + // these behaviors are for gitdiff.GetWhitespaceFlag + if whitespaceBehavior == "" { + ctx.Data["WhitespaceBehavior"] = defaultWhitespaceBehavior + } else { + ctx.Data["WhitespaceBehavior"] = whitespaceBehavior + } +} + +// SetShowOutdatedComments set the show outdated comments option as context variable +func SetShowOutdatedComments(ctx *context.Context) { + showOutdatedCommentsValue := ctx.FormString("show-outdated") + // var showOutdatedCommentsValue string + + if showOutdatedCommentsValue != "true" && showOutdatedCommentsValue != "false" { + // invalid or no value for this form string -> use default or stored user setting + if ctx.IsSigned { + showOutdatedCommentsValue, _ = user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, "false") + } else { + // not logged in user -> use the default value + showOutdatedCommentsValue = "false" + } + } else { + // valid value -> update user setting if user is logged in + if ctx.IsSigned { + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, showOutdatedCommentsValue) + } + } + + showOutdatedComments, _ := strconv.ParseBool(showOutdatedCommentsValue) + ctx.Data["ShowOutdatedComments"] = showOutdatedComments +} diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go new file mode 100644 index 0000000..0acf966 --- /dev/null +++ b/routers/web/repo/migrate.go @@ -0,0 +1,310 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models" + admin_model "code.gitea.io/gitea/models/admin" + "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/migrations" + "code.gitea.io/gitea/services/task" +) + +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.FormInt("service_type")) + + setMigrationContextData(ctx, serviceType) + + if serviceType == 0 { + ctx.Data["Org"] = ctx.FormString("org") + ctx.Data["Mirror"] = ctx.FormString("mirror") + + ctx.HTML(http.StatusOK, tplMigrate) + return + } + + ctx.Data["private"] = getRepoPrivate(ctx) + ctx.Data["mirror"] = ctx.FormString("mirror") == "1" + ctx.Data["lfs"] = ctx.FormString("lfs") == "1" + ctx.Data["wiki"] = ctx.FormString("wiki") == "1" + ctx.Data["milestones"] = ctx.FormString("milestones") == "1" + ctx.Data["labels"] = ctx.FormString("labels") == "1" + ctx.Data["issues"] = ctx.FormString("issues") == "1" + ctx.Data["pull_requests"] = ctx.FormString("pull_requests") == "1" + ctx.Data["releases"] = ctx.FormString("releases") == "1" + + ctxUser := checkContextUser(ctx, ctx.FormInt64("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 *user_model.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 repo_model.IsErrReachLimitOfRepo(err): + maxCreationLimit := owner.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.RenderWithErr(msg, tpl, form) + case repo_model.IsErrRepoAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) + case repo_model.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 db.IsErrNameReserved(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form) + default: + err = util.SanitizeErrorCredentialURLs(err) + 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", addrErr.Host), tpl, form) + case addrErr.IsPermissionDenied: + if addrErr.LocalPath { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied"), tpl, form) + } else { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_blocked"), 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", "unknown"), tpl, form) + } + } else { + log.Error("Error whilst updating url: %v", err) + ctx.RenderWithErr(ctx.Tr("form.url_error", "unknown"), 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 + } + + if form.Mirror && setting.Mirror.DisableNewPull { + ctx.Error(http.StatusBadRequest, "MigratePost: the site administrator has disabled creation of new mirrors") + return + } + + setMigrationContextData(ctx, form.Service) + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + tpl := base.TplName("repo/migrate/" + form.Service.Name()) + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + + 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.Doer) + } + 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.Doer) + if err != nil { + ctx.Data["Err_LFSEndpoint"] = true + handleMigrateRemoteAddrError(ctx, err, tpl, form) + return + } + } + + opts := migrations.MigrateOptions{ + OriginalURL: form.CloneAddr, + GitServiceType: form.Service, + CloneAddr: remoteAddr, + RepoName: form.RepoName, + Description: form.Description, + Private: form.Private || setting.Repository.ForcePrivate, + Mirror: form.Mirror, + 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 = repo_model.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false) + if err != nil { + handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form) + return + } + + err = task.MigrateRepository(ctx, ctx.Doer, ctxUser, opts) + if err == nil { + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(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.title") + + ctx.Data["LFSActive"] = setting.LFS.StartServer + ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + + // Plain git should be first + ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedFullGitService...) + ctx.Data["service"] = serviceType +} + +func MigrateRetryPost(ctx *context.Context) { + ok, err := quota_model.EvaluateForUser(ctx, ctx.Repo.Repository.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + if err := task.SetMigrateTaskMessage(ctx, ctx.Repo.Repository.ID, ctx.Locale.TrString("repo.settings.pull_mirror_sync_quota_exceeded")); err != nil { + log.Error("SetMigrateTaskMessage failed: %v", err) + ctx.ServerError("task.SetMigrateTaskMessage", err) + return + } + ctx.JSON(http.StatusRequestEntityTooLarge, map[string]any{ + "ok": false, + "error": ctx.Tr("repo.settings.pull_mirror_sync_quota_exceeded"), + }) + return + } + + if err := task.RetryMigrateTask(ctx, ctx.Repo.Repository.ID); err != nil { + log.Error("Retry task failed: %v", err) + ctx.ServerError("task.RetryMigrateTask", err) + return + } + ctx.JSONOK() +} + +func MigrateCancelPost(ctx *context.Context) { + migratingTask, err := admin_model.GetMigratingTask(ctx, ctx.Repo.Repository.ID) + if err != nil { + log.Error("GetMigratingTask: %v", err) + ctx.Redirect(ctx.Repo.Repository.Link()) + return + } + if migratingTask.Status == structs.TaskStatusRunning { + taskUpdate := &admin_model.Task{ID: migratingTask.ID, Status: structs.TaskStatusFailed, Message: "canceled"} + if err = taskUpdate.UpdateCols(ctx, "status", "message"); err != nil { + ctx.ServerError("task.UpdateCols", err) + return + } + } + ctx.Redirect(ctx.Repo.Repository.Link()) +} diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go new file mode 100644 index 0000000..1c53f73 --- /dev/null +++ b/routers/web/repo/milestone.go @@ -0,0 +1,304 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/issue" + + "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.FormString("state") == "closed" + sortType := ctx.FormString("sort") + keyword := ctx.FormTrim("q") + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + miles, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + RepoID: ctx.Repo.Repository.ID, + IsClosed: optional.Some(isShowClosed), + SortType: sortType, + Name: keyword, + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + + stats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, builder.And(builder.Eq{"id": ctx.Repo.Repository.ID}), keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + ctx.Data["OpenCount"] = stats.OpenCount + ctx.Data["ClosedCount"] = stats.ClosedCount + linkStr := "%s/milestones?state=%s&q=%s&sort=%s" + ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "open", + url.QueryEscape(keyword), url.QueryEscape(sortType)) + ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "closed", + url.QueryEscape(keyword), url.QueryEscape(sortType)) + + if ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + if err := issues_model.MilestoneList(miles).LoadTotalTrackedTimes(ctx); err != nil { + ctx.ServerError("LoadTotalTrackedTimes", err) + return + } + } + for _, m := range miles { + m.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, 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(int(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 = issues_model.NewMilestone(ctx, &issues_model.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 := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.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 := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.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 = issues_model.UpdateMilestone(ctx, 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) { + var toClose bool + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones") + return + } + id := ctx.ParamsInt64(":id") + + if err := issues_model.ChangeMilestoneStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeMilestoneStatusByIDAndRepoID", err) + } + return + } + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones?state=" + url.QueryEscape(ctx.Params(":action"))) +} + +// DeleteMilestone delete a milestone +func DeleteMilestone(ctx *context.Context) { + if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones") +} + +// MilestoneIssuesAndPulls lists all the issues and pull requests of the milestone +func MilestoneIssuesAndPulls(ctx *context.Context) { + milestoneID := ctx.ParamsInt64(":id") + projectID := ctx.FormInt64("project") + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) + if err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound("GetMilestoneByID", err) + return + } + + ctx.ServerError("GetMilestoneByID", err) + return + } + + milestone.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, milestone.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.Data["Title"] = milestone.Name + ctx.Data["Milestone"] = milestone + + issues(ctx, milestoneID, projectID, optional.None[bool]()) + + ret, _ := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["NewIssueChooseTemplate"] = len(ret) > 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/packages.go b/routers/web/repo/packages.go new file mode 100644 index 0000000..11874ab --- /dev/null +++ b/routers/web/repo/packages.go @@ -0,0 +1,78 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplPackagesList base.TplName = "repo/packages" +) + +// Packages displays a list of all packages in the repository +func Packages(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + query := ctx.FormTrim("q") + packageType := ctx.FormTrim("type") + + pvs, total, err := packages.SearchLatestVersions(ctx, &packages.PackageSearchOptions{ + Paginator: &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + }, + OwnerID: ctx.ContextUser.ID, + RepoID: ctx.Repo.Repository.ID, + Type: packages.Type(packageType), + Name: packages.SearchValue{Value: query}, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchLatestVersions", err) + return + } + + pds, err := packages.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + hasPackages, err := packages.HasRepositoryPackages(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("HasRepositoryPackages", err) + return + } + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["IsPackagesPage"] = true + ctx.Data["Query"] = query + ctx.Data["PackageType"] = packageType + ctx.Data["AvailableTypes"] = packages.TypeList + ctx.Data["HasPackages"] = hasPackages + if ctx.Repo != nil { + ctx.Data["CanWritePackages"] = ctx.IsUserRepoWriter([]unit.Type{unit.TypePackages}) || ctx.IsUserSiteAdmin() + } + ctx.Data["PackageDescriptors"] = pds + ctx.Data["Total"] = total + ctx.Data["RepositoryAccessMap"] = map[int64]bool{ctx.Repo.Repository.ID: true} // There is only the current repository + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + pager.AddParam(ctx, "q", "Query") + pager.AddParam(ctx, "type", "PackageType") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackagesList) +} diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go new file mode 100644 index 0000000..d234f6c --- /dev/null +++ b/routers/web/repo/patch.go @@ -0,0 +1,124 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "strings" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +const ( + tplPatchFile base.TplName = "repo/editor/patch" +) + +// NewDiffPatch render create patch page +func NewDiffPatch(ctx *context.Context) { + canCommit := renderCommitRights(ctx) + + ctx.Data["PageIsPatch"] = true + + 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["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + ctx.HTML(200, tplPatchFile) +} + +// NewDiffPatchPost response for sending patch page +func NewDiffPatchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditRepoFileForm) + + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + ctx.Data["PageIsPatch"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + 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["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + + if ctx.HasError() { + ctx.HTML(200, tplPatchFile) + 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 { + message = ctx.Locale.TrString("repo.editor.patch") + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdenitity := getGitIdentity(ctx, form.CommitMailID, tplPatchFile, &form) + if ctx.Written() { + return + } + + fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + Content: strings.ReplaceAll(form.Content, "\r", ""), + Author: gitIdenitity, + Committer: gitIdenitity, + }) + if err != nil { + if git_model.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(git_model.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/commit/" + fileResponse.Commit.SHA) + } +} diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go new file mode 100644 index 0000000..878b7ee --- /dev/null +++ b/routers/web/repo/projects.go @@ -0,0 +1,670 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/perm" + project_model "code.gitea.io/gitea/models/project" + attachment_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "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" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if unit.TypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableRepoProjects", nil) + return + } + + if ctx.Repo.Repository != nil { + if !ctx.Repo.CanRead(unit.TypeProjects) { + ctx.NotFound("MustEnableProjects", nil) + return + } + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects") + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + keyword := ctx.FormTrim("q") + repo := ctx.Repo.Repository + page := ctx.FormInt("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 := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.IssuePagingNum, + Page: page, + }, + RepoID: repo.ID, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + Type: project_model.TypeRepository, + Title: keyword, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + for i := range projects { + projects[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, 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(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(unit.TypeProjects) + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["IsProjectsPage"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(http.StatusOK, tplProjects) +} + +// RenderNewProject render creating a project page +func RenderNewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs() + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CancelLink"] = ctx.Repo.Repository.Link() + "/projects" + 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() { + RenderNewProject(ctx) + return + } + + if err := project_model.NewProject(ctx, &project_model.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: form.TemplateType, + CardType: form.CardType, + Type: project_model.TypeRepository, + }); 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) { + var toClose bool + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects") + return + } + id := ctx.ParamsInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSONRedirect(fmt.Sprintf("%s/projects/%d", ctx.Repo.RepoLink, id)) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects") +} + +// RenderEditProject allows a project to be edited +func RenderEditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["projectID"] = p.ID + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + ctx.Data["card_type"] = p.CardType + ctx.Data["redirect"] = ctx.FormString("redirect") + ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), p.ID) + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + projectID := ctx.ParamsInt64(":id") + + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), projectID) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + p, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + if project_model.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 + p.CardType = form.CardType + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + if ctx.FormString("redirect") == "project" { + ctx.Redirect(p.Link(ctx)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } +} + +// ViewProject renders the project with board view +func ViewProject(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + if project.CardType != project_model.CardTypeTextOnly { + issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + issuesAttachmentMap[issue.ID] = issueAttachment + } + } + } + ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + + if len(referencedIDs) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + ctx.Data["LinkedPRs"] = linkedPrsMap + + project.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, project.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.Data["IsProjectsPage"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["Project"] = project + ctx.Data["IssuesMap"] = issuesMap + ctx.Data["Columns"] = columns + + ctx.HTML(http.StatusOK, tplProjectsView) +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + if err := issues.LoadProjects(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + if _, err := issues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + + projectID := ctx.FormInt64("id") + for _, issue := range issues { + if issue.Project != nil && issue.Project.ID == projectID { + continue + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + if errors.Is(err, util.ErrPermissionDenied) { + continue + } + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + + ctx.JSONOK() +} + +// DeleteProjectColumn allows for the deletion of a project column +func DeleteProjectColumn(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + pb, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%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("ProjectColumn[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID), + }) + return + } + + if err := project_model.DeleteColumnByID(ctx, ctx.ParamsInt64(":columnID")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.JSONOK() +} + +// AddColumnToProjectPost allows a new column to be added to a project. +func AddColumnToProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + if err := project_model.NewColumn(ctx, &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + }); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSONOK() +} + +func checkProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return nil, nil + } + if column.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, ctx.Repo.Repository.ID), + }) + return nil, nil + } + return project, column +} + +// EditProjectColumn allows a project column's to be updated +func EditProjectColumn(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + _, column := checkProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSONOK() +} + +// SetDefaultProjectColumn set default column for uncategorized issues/pulls +func SetDefaultProjectColumn(ctx *context.Context) { + project, column := checkProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.JSONOK() +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotExist", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.NotFound("ProjectColumnNotExist", nil) + } else { + ctx.ServerError("GetProjectColumn", err) + } + return + } + + if column.ProjectID != project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("IssueNotExisting", nil) + } else { + ctx.ServerError("GetIssueByID", err) + } + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/repo/projects_test.go b/routers/web/repo/projects_test.go new file mode 100644 index 0000000..d61230a --- /dev/null +++ b/routers/web/repo/projects_test.go @@ -0,0 +1,27 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestCheckProjectColumnChangePermissions(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/projects/1/2") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + ctx.SetParams(":id", "1") + ctx.SetParams(":columnID", "2") + + project, column := checkProjectColumnChangePermissions(ctx) + assert.NotNil(t, project) + assert.NotNil(t, column) + assert.False(t, ctx.Written()) +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go new file mode 100644 index 0000000..14b03b9 --- /dev/null +++ b/routers/web/repo/pull.go @@ -0,0 +1,1843 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "html" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models" + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + pull_model "code.gitea.io/gitea/models/pull" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + issue_template "code.gitea.io/gitea/modules/issue/template" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/automerge" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/gitdiff" + notify_service "code.gitea.io/gitea/services/notify" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/gobwas/glob" +) + +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.yaml", + "PULL_REQUEST_TEMPLATE.yml", + "pull_request_template.md", + "pull_request_template.yaml", + "pull_request_template.yml", + ".forgejo/PULL_REQUEST_TEMPLATE.md", + ".forgejo/PULL_REQUEST_TEMPLATE.yaml", + ".forgejo/PULL_REQUEST_TEMPLATE.yml", + ".forgejo/pull_request_template.md", + ".forgejo/pull_request_template.yaml", + ".forgejo/pull_request_template.yml", + ".gitea/PULL_REQUEST_TEMPLATE.md", + ".gitea/PULL_REQUEST_TEMPLATE.yaml", + ".gitea/PULL_REQUEST_TEMPLATE.yml", + ".gitea/pull_request_template.md", + ".gitea/pull_request_template.yaml", + ".gitea/pull_request_template.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE.yaml", + ".github/PULL_REQUEST_TEMPLATE.yml", + ".github/pull_request_template.md", + ".github/pull_request_template.yaml", + ".github/pull_request_template.yml", +} + +func getRepository(ctx *context.Context, repoID int64) *repo_model.Repository { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByID", nil) + } else { + ctx.ServerError("GetRepositoryByID", err) + } + return nil + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + + if !perm.CanRead(unit.TypeCode) { + log.Trace("Permission Denied: User %-v cannot read %-v of repo %-v\n"+ + "User in repo has Permissions: %-+v", + ctx.Doer, + unit.TypeCode, + ctx.Repo, + perm) + ctx.NotFound("getRepository", nil) + return nil + } + return repo +} + +func updateForkRepositoryInContext(ctx *context.Context, forkRepo *repo_model.Repository) bool { + if forkRepo == nil { + ctx.NotFound("No repository in context", nil) + return false + } + + if forkRepo.IsEmpty { + log.Trace("Empty repository %-v", forkRepo) + ctx.NotFound("updateForkRepositoryInContext", nil) + return false + } + + if err := forkRepo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return false + } + + 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.Doer.ID && !repo_model.HasForkedRepo(ctx, ctx.Doer.ID, forkRepo.ID) + + ctx.Data["ForkRepo"] = forkRepo + + ownedOrgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return false + } + var orgs []*organization.Organization + for _, org := range ownedOrgs { + if forkRepo.OwnerID != org.ID && !repo_model.HasForkedRepo(ctx, org.ID, forkRepo.ID) { + orgs = append(orgs, org) + } + } + + traverseParentRepo := forkRepo + for { + if ctx.Doer.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 = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return false + } + } + + ctx.Data["CanForkToUser"] = canForkToUser + ctx.Data["Orgs"] = orgs + + if canForkToUser { + ctx.Data["ContextUser"] = ctx.Doer + } else if len(orgs) > 0 { + ctx.Data["ContextUser"] = orgs[0] + } else { + ctx.Data["CanForkRepo"] = false + ctx.RenderWithErr(ctx.Tr("repo.fork_no_valid_owners"), tplFork, nil) + return false + } + + branches, err := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + ListOptions: db.ListOptions{ + ListAll: true, + }, + IsDeletedBranch: optional.Some(false), + // Add it as the first option + ExcludeBranchNames: []string{ctx.Repo.Repository.DefaultBranch}, + }) + if err != nil { + ctx.ServerError("FindBranchNames", err) + return false + } + ctx.Data["Branches"] = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) + + return true +} + +// ForkByID redirects (with 301 Moved Permanently) to the repository's `/fork` page +func ForkByID(ctx *context.Context) { + ctx.Redirect(ctx.Repo.Repository.Link()+"/fork", http.StatusMovedPermanently) +} + +// Fork renders the repository fork page +func Fork(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_fork") + + if ctx.Doer.CanForkRepo() { + ctx.Data["CanForkRepo"] = true + } else { + maxCreationLimit := ctx.Doer.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.Flash.Error(msg, true) + } + + if !updateForkRepositoryInContext(ctx, ctx.Repo.Repository) { + 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") + ctx.Data["CanForkRepo"] = true + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + + forkRepo := ctx.Repo.Repository + if !updateForkRepositoryInContext(ctx, forkRepo) { + return + } + + ctx.Data["ContextUser"] = ctxUser + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplFork) + return + } + + var err error + traverseParentRepo := forkRepo + for { + if ctxUser.ID == traverseParentRepo.OwnerID { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + return + } + repo := repo_model.GetForkedRepo(ctx, ctxUser.ID, traverseParentRepo.ID) + if repo != nil { + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) + return + } + if !traverseParentRepo.IsFork { + break + } + traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + } + + // Check if user is allowed to create repo's on the organization. + if ctxUser.IsOrganization() { + isAllowedToFork, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CanCreateOrgRepo", err) + return + } else if !isAllowedToFork { + ctx.Error(http.StatusForbidden) + return + } + } + + repo, err := repo_service.ForkRepositoryAndUpdates(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ + BaseRepo: forkRepo, + Name: form.RepoName, + Description: form.Description, + SingleBranch: form.ForkSingleBranch, + }) + if err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case repo_model.IsErrReachLimitOfRepo(err): + maxCreationLimit := ctxUser.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.RenderWithErr(msg, tplFork, &form) + case repo_model.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + case repo_model.IsErrRepoFilesAlreadyExist(err): + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplFork, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplFork, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplFork, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplFork, form) + } + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplFork, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.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() + "/" + url.PathEscape(repo.Name)) +} + +func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return nil, false + } + if err = issue.LoadPoster(ctx); err != nil { + ctx.ServerError("LoadPoster", err) + return nil, false + } + if err := issue.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return nil, false + } + ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, emoji.ReplaceAliases(issue.Title)) + ctx.Data["Issue"] = issue + + if !issue.IsPull { + ctx.NotFound("ViewPullCommits", nil) + return nil, false + } + + if err = issue.LoadPullRequest(ctx); err != nil { + ctx.ServerError("LoadPullRequest", err) + return nil, false + } + + if err = issue.PullRequest.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return nil, false + } + + if ctx.IsSigned { + // Update issue-user. + if err = activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("ReadBy", err) + return nil, false + } + } + + return issue, true +} + +func setMergeTarget(ctx *context.Context, pull *issues_model.PullRequest) { + if ctx.Repo.Owner.Name == pull.MustHeadUserName(ctx) { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + ":" + pull.HeadBranch + } else { + ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + "/" + pull.HeadRepo.Name + ":" + pull.HeadBranch + } + + if pull.Flow == issues_model.PullRequestFlowAGit { + ctx.Data["MadeUsingAGit"] = true + } + + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["HeadBranchLink"] = pull.GetHeadBranchLink(ctx) + ctx.Data["BaseBranchLink"] = pull.GetBaseBranchLink(ctx) +} + +// GetPullDiffStats get Pull Requests diff stats +func GetPullDiffStats(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + mergeBaseCommitID := GetMergedBaseCommitID(ctx, issue) + + if mergeBaseCommitID == "" { + ctx.NotFound("PullFiles", nil) + return + } + + headCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + + diffOptions := &gitdiff.DiffOptions{ + BeforeCommitID: mergeBaseCommitID, + AfterCommitID: headCommitID, + MaxLines: setting.Git.MaxGitDiffLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: setting.Git.MaxGitDiffFiles, + WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), + } + + diff, err := gitdiff.GetPullDiffStats(ctx.Repo.GitRepo, diffOptions) + if err != nil { + ctx.ServerError("GetPullDiffStats", err) + return + } + + ctx.Data["Diff"] = diff +} + +func GetMergedBaseCommitID(ctx *context.Context, issue *issues_model.Issue) string { + pull := issue.PullRequest + + var baseCommit string + // Some migrated PR won't have any Base SHA and lose history, try to get one + if pull.MergeBase == "" { + var commitSHA, parentCommit string + // If there is a head or a patch file, and it is readable, grab info + commitSHA, err := ctx.Repo.GitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + // Head File does not exist, try the patch + commitSHA, err = ctx.Repo.GitRepo.ReadPatchCommit(pull.Index) + if err == nil { + // Recreate pull head in files for next time + if err := ctx.Repo.GitRepo.SetReference(pull.GetGitRefName(), commitSHA); err != nil { + log.Error("Could not write head file", err) + } + } else { + // There is no history available + log.Trace("No history file available for PR %d", pull.Index) + } + } + if commitSHA != "" { + // Get immediate parent of the first commit in the patch, grab history back + parentCommit, _, err = git.NewCommand(ctx, "rev-list", "-1", "--skip=1").AddDynamicArguments(commitSHA).RunStdString(&git.RunOpts{Dir: ctx.Repo.GitRepo.Path}) + if err == nil { + parentCommit = strings.TrimSpace(parentCommit) + } + // Special case on Git < 2.25 that doesn't fail on immediate empty history + if err != nil || parentCommit == "" { + log.Info("No known parent commit for PR %d, error: %v", pull.Index, err) + // bring at least partial history if it can work + parentCommit = commitSHA + } + } + baseCommit = parentCommit + } else { + // Keep an empty history or original commit + baseCommit = pull.MergeBase + } + + return baseCommit +} + +// PrepareMergedViewPullInfo show meta information for a merged pull request view page +func PrepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.CompareInfo { + pull := issue.PullRequest + + setMergeTarget(ctx, pull) + ctx.Data["HasMerged"] = true + + baseCommit := GetMergedBaseCommitID(ctx, issue) + + compareInfo, err := ctx.Repo.GitRepo.GetCompareInfo(ctx.Repo.Repository.RepoPath(), + baseCommit, pull.GetGitRefName(), false, false) + 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"] = len(compareInfo.Commits) + ctx.Data["NumFiles"] = compareInfo.NumFiles + + if len(compareInfo.Commits) != 0 { + sha := compareInfo.Commits[0].ID.String() + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) + } + + if len(commitStatuses) != 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) + } + } + + return compareInfo +} + +// PrepareViewPullInfo show meta information for a pull request preview page +func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.CompareInfo { + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + + repo := ctx.Repo.Repository + pull := issue.PullRequest + + if err := pull.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return nil + } + + if err := pull.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return nil + } + + setMergeTarget(ctx, pull) + + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, pull.BaseBranch) + if err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return nil + } + ctx.Data["EnableStatusCheck"] = pb != nil && pb.EnableStatusCheck + + var baseGitRepo *git.Repository + if pull.BaseRepoID == ctx.Repo.Repository.ID && ctx.Repo.GitRepo != nil { + baseGitRepo = ctx.Repo.GitRepo + } else { + baseGitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo) + 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 := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) + } + + if len(commitStatuses) > 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) + } + + compareInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), + pull.MergeBase, pull.GetGitRefName(), false, false) + 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"] = len(compareInfo.Commits) + ctx.Data["NumFiles"] = compareInfo.NumFiles + return compareInfo + } + + var headBranchExist bool + var headBranchSha string + // HeadRepo may be missing + if pull.HeadRepo != nil { + headGitRepo, err := gitrepo.OpenRepository(ctx, pull.HeadRepo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer headGitRepo.Close() + + if pull.Flow == issues_model.PullRequestFlowGithub { + headBranchExist = headGitRepo.IsBranchExist(pull.HeadBranch) + } else { + headBranchExist = git.IsReferenceExist(ctx, baseGitRepo.Path, pull.GetGitRefName()) + } + + if headBranchExist { + if pull.Flow != issues_model.PullRequestFlowGithub { + headBranchSha, err = baseGitRepo.GetRefCommitID(pull.GetGitRefName()) + } else { + headBranchSha, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) + } + if err != nil { + ctx.ServerError("GetBranchCommitID", err) + return nil + } + } + } + + if headBranchExist { + var err error + ctx.Data["UpdateAllowed"], ctx.Data["UpdateByRebaseAllowed"], err = pull_service.IsUserAllowedToUpdate(ctx, pull, ctx.Doer) + if err != nil { + ctx.ServerError("IsUserAllowedToUpdate", err) + return nil + } + ctx.Data["GetCommitMessages"] = pull_service.GetSquashMergeCommitMessages(ctx, pull) + } else { + ctx.Data["GetCommitMessages"] = "" + } + + 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"] = ctx.Locale.Tr("repo.pull.deleted_branch", 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 := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) + } + + if len(commitStatuses) > 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) + } + + if pb != nil && pb.EnableStatusCheck { + var missingRequiredChecks []string + for _, requiredContext := range pb.StatusCheckContexts { + contextFound := false + matchesRequiredContext := createRequiredContextMatcher(requiredContext) + for _, presentStatus := range commitStatuses { + if matchesRequiredContext(presentStatus.Context) { + contextFound = true + break + } + } + + if !contextFound { + missingRequiredChecks = append(missingRequiredChecks, requiredContext) + } + } + ctx.Data["MissingRequiredChecks"] = missingRequiredChecks + + ctx.Data["is_context_required"] = func(context string) bool { + for _, c := range pb.StatusCheckContexts { + if c == context { + return true + } + if gp, err := glob.Compile(c); err != nil { + // All newly created status_check_contexts are checked to ensure they are valid glob expressions before being stored in the database. + // But some old status_check_context created before glob was introduced may be invalid glob expressions. + // So log the error here for debugging. + log.Error("compile glob %q: %v", c, err) + } else if gp.Match(context) { + return true + } + } + return false + } + ctx.Data["RequiredStatusCheckState"] = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, pb.StatusCheckContexts) + } + + ctx.Data["HeadBranchMovedOn"] = headBranchSha != sha + ctx.Data["HeadBranchCommitID"] = headBranchSha + ctx.Data["PullHeadCommitID"] = sha + + if pull.HeadRepo == nil || !headBranchExist || (!pull.Issue.IsClosed && (headBranchSha != sha)) { + ctx.Data["IsPullRequestBroken"] = true + if pull.IsSameRepo() { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = ctx.Locale.Tr("repo.pull.deleted_branch", pull.HeadBranch) + } else { + ctx.Data["HeadTarget"] = pull.HeadRepo.OwnerName + ":" + pull.HeadBranch + } + } + + compareInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), + git.BranchPrefix+pull.BaseBranch, pull.GetGitRefName(), false, false) + 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 + } + + if compareInfo.HeadCommitID == compareInfo.MergeBase { + ctx.Data["IsNothingToCompare"] = true + } + + if pull.IsWorkInProgress(ctx) { + ctx.Data["IsPullWorkInProgress"] = true + ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx) + } + + if pull.IsFilesConflicted() { + ctx.Data["IsPullFilesConflicted"] = true + ctx.Data["ConflictedFiles"] = pull.ConflictedFiles + } + + ctx.Data["NumCommits"] = len(compareInfo.Commits) + ctx.Data["NumFiles"] = compareInfo.NumFiles + return compareInfo +} + +func createRequiredContextMatcher(requiredContext string) func(string) bool { + if gp, err := glob.Compile(requiredContext); err == nil { + return func(contextToCheck string) bool { + return gp.Match(contextToCheck) + } + } + + return func(contextToCheck string) bool { + return requiredContext == contextToCheck + } +} + +type pullCommitList struct { + Commits []pull_service.CommitInfo `json:"commits"` + LastReviewCommitSha string `json:"last_review_commit_sha"` + Locale map[string]any `json:"locale"` +} + +// GetPullCommits get all commits for given pull request +func GetPullCommits(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + resp := &pullCommitList{} + + commits, lastReviewCommitSha, err := pull_service.GetPullCommits(ctx, issue) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + + // Get the needed locale + resp.Locale = map[string]any{ + "lang": ctx.Locale.Language(), + "show_all_commits": ctx.Tr("repo.pulls.show_all_commits"), + "stats_num_commits": ctx.TrN(len(commits), "repo.activity.git_stats_commit_1", "repo.activity.git_stats_commit_n", len(commits)), + "show_changes_since_your_last_review": ctx.Tr("repo.pulls.show_changes_since_your_last_review"), + "select_commit_hold_shift_for_range": ctx.Tr("repo.pulls.select_commit_hold_shift_for_range"), + } + + resp.Commits = commits + resp.LastReviewCommitSha = lastReviewCommitSha + + ctx.JSON(http.StatusOK, resp) +} + +// ViewPullCommits show commits for a pull request +func ViewPullCommits(ctx *context.Context) { + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullCommits"] = true + + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + 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 := processGitCommits(ctx, prInfo.Commits) + ctx.Data["Commits"] = commits + ctx.Data["CommitCount"] = len(commits) + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + + // For PR commits page + PrepareBranchList(ctx) + if ctx.Written() { + return + } + getBranchData(ctx, issue) + ctx.HTML(http.StatusOK, tplPullCommits) +} + +// ViewPullFiles render pull request changed files list page +func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommit string, willShowSpecifiedCommitRange, willShowSpecifiedCommit bool) { + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullFiles"] = true + + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + var ( + startCommitID string + endCommitID string + gitRepo = ctx.Repo.GitRepo + ) + + var prInfo *git.CompareInfo + if pull.HasMerged { + prInfo = PrepareMergedViewPullInfo(ctx, issue) + } else { + prInfo = PrepareViewPullInfo(ctx, issue) + } + + // Validate the given commit sha to show (if any passed) + if willShowSpecifiedCommit || willShowSpecifiedCommitRange { + foundStartCommit := len(specifiedStartCommit) == 0 + foundEndCommit := len(specifiedEndCommit) == 0 + + if !(foundStartCommit && foundEndCommit) { + for _, commit := range prInfo.Commits { + if commit.ID.String() == specifiedStartCommit { + foundStartCommit = true + } + if commit.ID.String() == specifiedEndCommit { + foundEndCommit = true + } + + if foundStartCommit && foundEndCommit { + break + } + } + } + + if !(foundStartCommit && foundEndCommit) { + ctx.NotFound("Given SHA1 not found for this PR", nil) + return + } + } + + if ctx.Written() { + return + } else if prInfo == nil { + ctx.NotFound("ViewPullFiles", nil) + return + } + + headCommitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + + ctx.Data["IsShowingOnlySingleCommit"] = willShowSpecifiedCommit + + if willShowSpecifiedCommit || willShowSpecifiedCommitRange { + if len(specifiedEndCommit) > 0 { + endCommitID = specifiedEndCommit + } else { + endCommitID = headCommitID + } + if len(specifiedStartCommit) > 0 { + startCommitID = specifiedStartCommit + } else { + startCommitID = prInfo.MergeBase + } + ctx.Data["IsShowingAllCommits"] = false + } else { + endCommitID = headCommitID + startCommitID = prInfo.MergeBase + ctx.Data["IsShowingAllCommits"] = true + } + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["AfterCommitID"] = endCommitID + ctx.Data["BeforeCommitID"] = startCommitID + + fileOnly := ctx.FormBool("file-only") + + maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles + files := ctx.FormStrings("files") + if fileOnly && (len(files) == 2 || len(files) == 1) { + maxLines, maxFiles = -1, -1 + } + + diffOptions := &gitdiff.DiffOptions{ + AfterCommitID: endCommitID, + SkipTo: ctx.FormString("skip-to"), + MaxLines: maxLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: maxFiles, + WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), + } + + if !willShowSpecifiedCommit { + diffOptions.BeforeCommitID = startCommitID + } + + var methodWithError string + var diff *gitdiff.Diff + + // if we're not logged in or only a single commit (or commit range) is shown we + // have to load only the diff and not get the viewed information + // as the viewed information is designed to be loaded only on latest PR + // diff and if you're signed in. + if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange { + diff, err = gitdiff.GetDiff(ctx, gitRepo, diffOptions, files...) + methodWithError = "GetDiff" + } else { + diff, err = gitdiff.SyncAndGetUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diffOptions, files...) + methodWithError = "SyncAndGetUserSpecificDiff" + } + if err != nil { + ctx.ServerError(methodWithError, err) + return + } + + ctx.PageData["prReview"] = map[string]any{ + "numberOfFiles": diff.NumFiles, + "numberOfViewedFiles": diff.NumViewedFiles, + } + + if err = diff.LoadComments(ctx, issue, ctx.Doer, ctx.Data["ShowOutdatedComments"].(bool)); err != nil { + ctx.ServerError("LoadComments", err) + return + } + + for _, file := range diff.Files { + for _, section := range file.Sections { + for _, line := range section.Lines { + for _, comments := range line.Conversations { + for _, comment := range comments { + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + } + } + } + } + } + + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) + if err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return + } + + if pb != nil { + glob := pb.GetProtectedFilePatterns() + if len(glob) != 0 { + for _, file := range diff.Files { + file.IsProtected = pb.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 + } + + // determine if the user viewing the pull request can edit the head branch + if ctx.Doer != nil && pull.HeadRepo != nil && !pull.HasMerged { + headRepoPerm, err := access_model.GetUserRepoPermission(ctx, pull.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + ctx.Data["HeadBranchIsEditable"] = pull.HeadRepo.CanEnableEditor() && issues_model.CanMaintainerWriteToBranch(ctx, headRepoPerm, pull.HeadBranch, ctx.Doer) + ctx.Data["SourceRepoLink"] = pull.HeadRepo.Link() + ctx.Data["HeadBranch"] = pull.HeadBranch + } + + if ctx.IsSigned && ctx.Doer != nil { + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + } + + setCompareContext(ctx, baseCommit, commit, ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + handleTeamMentions(ctx) + if ctx.Written() { + return + } + + currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue) + if err != nil && !issues_model.IsErrReviewNotExist(err) { + ctx.ServerError("GetCurrentReview", err) + return + } + numPendingCodeComments := int64(0) + if currentReview != nil { + numPendingCodeComments, err = issues_model.CountComments(ctx, &issues_model.FindCommentsOptions{ + Type: issues_model.CommentTypeCode, + ReviewID: currentReview.ID, + IssueID: issue.ID, + }) + if err != nil { + ctx.ServerError("CountComments", err) + return + } + } + ctx.Data["CurrentReview"] = currentReview + ctx.Data["PendingCodeCommentNumber"] = numPendingCodeComments + + getBranchData(ctx, issue) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + // For files changed page + PrepareBranchList(ctx) + if ctx.Written() { + return + } + upload.AddUploadContext(ctx, "comment") + + ctx.HTML(http.StatusOK, tplPullFiles) +} + +func ViewPullFilesForSingleCommit(ctx *context.Context) { + viewPullFiles(ctx, "", ctx.Params("sha"), true, true) +} + +func ViewPullFilesForRange(ctx *context.Context) { + viewPullFiles(ctx, ctx.Params("shaFrom"), ctx.Params("shaTo"), true, false) +} + +func ViewPullFilesStartingFromCommit(ctx *context.Context) { + viewPullFiles(ctx, "", ctx.Params("sha"), true, false) +} + +func ViewPullFilesForAllCommitsOfPr(ctx *context.Context) { + viewPullFiles(ctx, "", "", false, false) +} + +// UpdatePullRequest merge PR's baseBranch into headBranch +func UpdatePullRequest(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + if issue.IsClosed { + ctx.NotFound("MergePullRequest", nil) + return + } + if issue.PullRequest.HasMerged { + ctx.NotFound("MergePullRequest", nil) + return + } + + rebase := ctx.FormString("style") == "rebase" + + if err := issue.PullRequest.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return + } + if err := issue.PullRequest.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return + } + + allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, issue.PullRequest, ctx.Doer) + if err != nil { + ctx.ServerError("IsUserAllowedToMerge", err) + return + } + + // ToDo: add check if maintainers are allowed to change branch ... (need migration & co) + if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) { + ctx.Flash.Error(ctx.Tr("repo.pulls.update_not_allowed")) + ctx.Redirect(issue.Link()) + return + } + + // default merge commit message + message := fmt.Sprintf("Merge branch '%s' into %s", issue.PullRequest.BaseBranch, issue.PullRequest.HeadBranch) + + if err = pull_service.Update(ctx, issue.PullRequest, ctx.Doer, message, rebase); err != nil { + if models.IsErrMergeConflicts(err) { + conflictError := err.(models.ErrMergeConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "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(issue.Link()) + return + } else if models.IsErrRebaseConflicts(err) { + conflictError := err.(models.ErrRebaseConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "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("UpdatePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.Redirect(issue.Link()) + return + } + ctx.Flash.Error(err.Error()) + ctx.Redirect(issue.Link()) + return + } + + time.Sleep(1 * time.Second) + + ctx.Flash.Success(ctx.Tr("repo.pulls.update_branch_success")) + ctx.Redirect(issue.Link()) +} + +// MergePullRequest response for merging pull request +func MergePullRequest(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MergePullRequestForm) + issue, ok := getPullInfo(ctx) + if !ok { + return + } + + pr := issue.PullRequest + pr.Issue = issue + pr.Issue.Repo = ctx.Repo.Repository + + manuallyMerged := repo_model.MergeStyle(form.Do) == repo_model.MergeStyleManuallyMerged + + mergeCheckType := pull_service.MergeCheckTypeGeneral + if form.MergeWhenChecksSucceed { + mergeCheckType = pull_service.MergeCheckTypeAuto + } + if manuallyMerged { + mergeCheckType = pull_service.MergeCheckTypeManually + } + + // start with merging by checking + if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { + switch { + case errors.Is(err, pull_service.ErrIsClosed): + if issue.IsPull { + ctx.JSONError(ctx.Tr("repo.pulls.is_closed")) + } else { + ctx.JSONError(ctx.Tr("repo.issues.closed_title")) + } + case errors.Is(err, pull_service.ErrUserNotAllowedToMerge): + ctx.JSONError(ctx.Tr("repo.pulls.update_not_allowed")) + case errors.Is(err, pull_service.ErrHasMerged): + ctx.JSONError(ctx.Tr("repo.pulls.has_merged")) + case errors.Is(err, pull_service.ErrIsWorkInProgress): + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_wip")) + case errors.Is(err, pull_service.ErrNotMergeableState): + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) + case models.IsErrDisallowedToMerge(err): + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) + case asymkey_service.IsErrWontSign(err): + ctx.JSONError(err.Error()) // has no translation ... + case errors.Is(err, pull_service.ErrDependenciesLeft): + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + default: + ctx.ServerError("WebCheck", err) + } + + return + } + + // handle manually-merged mark + if manuallyMerged { + if err := pull_service.MergedManually(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { + switch { + case models.IsErrInvalidMergeStyle(err): + ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) + case strings.Contains(err.Error(), "Wrong commit ID"): + ctx.JSONError(ctx.Tr("repo.pulls.wrong_commit_id")) + default: + ctx.ServerError("MergedManually", err) + } + + return + } + + ctx.JSONRedirect(issue.Link()) + return + } + + message := strings.TrimSpace(form.MergeTitleField) + if len(message) == 0 { + var err error + message, _, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pr, repo_model.MergeStyle(form.Do)) + if err != nil { + ctx.ServerError("GetDefaultMergeMessage", err) + return + } + } + + form.MergeMessageField = strings.TrimSpace(form.MergeMessageField) + if len(form.MergeMessageField) > 0 { + message += "\n\n" + form.MergeMessageField + } + + if form.MergeWhenChecksSucceed { + // delete all scheduled auto merges + _ = pull_model.DeleteScheduledAutoMerge(ctx, pr.ID) + // schedule auto merge + scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message) + if err != nil { + ctx.ServerError("ScheduleAutoMerge", err) + return + } else if scheduled { + // nothing more to do ... + ctx.Flash.Success(ctx.Tr("repo.pulls.auto_merge_newly_scheduled")) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pr.Index)) + return + } + } + + if err := pull_service.Merge(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { + if models.IsErrInvalidMergeStyle(err) { + ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) + } else if models.IsErrMergeConflicts(err) { + conflictError := err.(models.ErrMergeConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "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.JSONRedirect(issue.Link()) + } else if models.IsErrRebaseConflicts(err) { + conflictError := err.(models.ErrRebaseConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "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.JSONRedirect(issue.Link()) + } else if models.IsErrMergeUnrelatedHistories(err) { + log.Debug("MergeUnrelatedHistories error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.unrelated_histories")) + ctx.JSONRedirect(issue.Link()) + } else if git.IsErrPushOutOfDate(err) { + log.Debug("MergePushOutOfDate error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.merge_out_of_date")) + ctx.JSONRedirect(issue.Link()) + } else if models.IsErrSHADoesNotMatch(err) { + log.Debug("MergeHeadOutOfDate error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.head_out_of_date")) + ctx.JSONRedirect(issue.Link()) + } 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.RenderToHTML(tplAlertDetails, map[string]any{ + "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.JSONRedirect(issue.Link()) + } else { + ctx.ServerError("Merge", err) + } + return + } + log.Trace("Pull request merged: %d", pr.ID) + + if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil { + ctx.ServerError("stopTimerIfAvailable", err) + return + } + + log.Trace("Pull request merged: %d", pr.ID) + + if form.DeleteBranchAfterMerge { + var headRepo *git.Repository + if ctx.Repo != nil && ctx.Repo.Repository != nil && pr.HeadRepoID == ctx.Repo.Repository.ID && ctx.Repo.GitRepo != nil { + headRepo = ctx.Repo.GitRepo + } else { + var err error + headRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) + return + } + defer headRepo.Close() + } + + if err := repo_service.DeleteBranchAfterMerge(ctx, ctx.Doer, pr, headRepo); err != nil { + switch { + case errors.Is(err, repo_service.ErrBranchIsDefault): + ctx.Flash.Error(ctx.Tr("repo.pulls.delete_after_merge.head_branch.is_default")) + case errors.Is(err, git_model.ErrBranchIsProtected): + ctx.Flash.Error(ctx.Tr("repo.pulls.delete_after_merge.head_branch.is_protected")) + case errors.Is(err, util.ErrPermissionDenied): + ctx.Flash.Error(ctx.Tr("repo.pulls.delete_after_merge.head_branch.insufficient_branch")) + default: + ctx.ServerError("DeleteBranchAfterMerge", err) + } + + ctx.JSONRedirect(issue.Link()) + return + } + } + + ctx.JSONRedirect(issue.Link()) +} + +// CancelAutoMergePullRequest cancels a scheduled pr +func CancelAutoMergePullRequest(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + + if err := automerge.RemoveScheduledAutoMerge(ctx, ctx.Doer, issue.PullRequest); err != nil { + if db.IsErrNotExist(err) { + ctx.Flash.Error(ctx.Tr("repo.pulls.auto_merge_not_scheduled")) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) + return + } + ctx.ServerError("RemoveScheduledAutoMerge", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.pulls.auto_merge_canceled_schedule")) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) +} + +func stopTimerIfAvailable(ctx *context.Context, user *user_model.User, issue *issues_model.Issue) error { + if issues_model.StopwatchExists(ctx, user.ID, issue.ID) { + if err := issues_model.CreateOrStopIssueStopwatch(ctx, 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["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypePullRequests) + + var ( + repo = ctx.Repo.Repository + attachments []string + ) + + ci := ParseCompareInfo(ctx) + defer func() { + if ci != nil && ci.HeadGitRepo != nil { + ci.HeadGitRepo.Close() + } + }() + if ctx.Written() { + return + } + + labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, true) + if ctx.Written() { + return + } + + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + if util.IsEmptyString(form.Title) { + ctx.JSONError(ctx.Tr("repo.issues.new.title_empty")) + return + } + + content := form.Content + if filename := ctx.Req.Form.Get("template-file"); filename != "" { + if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil { + content = issue_template.RenderToMarkdown(template, ctx.Req.Form) + } + } + + pullIssue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Title: form.Title, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, + MilestoneID: milestoneID, + IsPull: true, + Content: content, + } + pullRequest := &issues_model.PullRequest{ + HeadRepoID: ci.HeadRepo.ID, + BaseRepoID: repo.ID, + HeadBranch: ci.HeadBranch, + BaseBranch: ci.BaseBranch, + HeadRepo: ci.HeadRepo, + BaseRepo: repo, + MergeBase: ci.CompareInfo.MergeBase, + Type: issues_model.PullRequestGitea, + AllowMaintainerEdit: form.AllowMaintainerEdit, + } + // 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(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { + switch { + case errors.Is(err, user_model.ErrBlockedByUser): + ctx.JSONError(ctx.Tr("repo.pulls.blocked_by_user")) + case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + case git.IsErrPushRejected(err): + pushrejErr := err.(*git.ErrPushRejected) + message := pushrejErr.Message + if len(message) == 0 { + ctx.JSONError(ctx.Tr("repo.pulls.push_rejected_no_message")) + return + } + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "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.JSONError(flashError) + default: + // It's an unexpected error. + // If it happens, we should add another case to handle it. + log.Error("Unexpected error of NewPullRequest: %T %s", err, err) + ctx.ServerError("CompareAndPullRequest", err) + } + ctx.ServerError("NewPullRequest", err) + return + } + + if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) { + if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil { + if !errors.Is(err, util.ErrPermissionDenied) { + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + } + + log.Trace("Pull request created: %d/%d", repo.ID, pullIssue.ID) + ctx.JSONRedirect(pullIssue.Link()) +} + +// CleanUpPullRequest responses for delete merged branch when PR has been merged +func CleanUpPullRequest(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + + pr := issue.PullRequest + + // Don't cleanup unmerged and unclosed PRs + if !pr.HasMerged && !issue.IsClosed { + ctx.NotFound("CleanUpPullRequest", nil) + return + } + + // Don't cleanup when there are other PR's that use this branch as head branch. + exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch) + if err != nil { + ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err) + return + } + if exist { + ctx.NotFound("CleanUpPullRequest", nil) + return + } + + if err := pr.LoadHeadRepo(ctx); 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(ctx); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return + } else if err = pr.HeadRepo.LoadOwner(ctx); err != nil { + ctx.ServerError("HeadRepo.LoadOwner", err) + return + } + + perm, err := access_model.GetUserRepoPermission(ctx, pr.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !perm.CanWrite(unit.TypeCode) { + ctx.NotFound("CleanUpPullRequest", nil) + return + } + + fullBranchName := pr.HeadRepo.Owner.Name + "/" + pr.HeadBranch + + var gitBaseRepo *git.Repository + + // Assume that the base repo is the current context (almost certainly) + if ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.BaseRepoID && ctx.Repo.GitRepo != nil { + gitBaseRepo = ctx.Repo.GitRepo + } else { + // If not just open it + gitBaseRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.BaseRepo.FullName()), err) + return + } + defer gitBaseRepo.Close() + } + + // Now assume that the head repo is the same as the base repo (reasonable chance) + gitRepo := gitBaseRepo + // But if not: is it the same as the context? + if pr.BaseRepoID != pr.HeadRepoID && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.HeadRepoID && ctx.Repo.GitRepo != nil { + gitRepo = ctx.Repo.GitRepo + } else if pr.BaseRepoID != pr.HeadRepoID { + // Otherwise just load it up + gitRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) + return + } + defer gitRepo.Close() + } + + defer func() { + ctx.JSONRedirect(issue.Link()) + }() + + // 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 + } + + deleteBranch(ctx, pr, gitRepo) +} + +func deleteBranch(ctx *context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) { + fullBranchName := pr.HeadRepo.FullName() + ":" + pr.HeadBranch + + if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil { + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + return + } + + if err := repo_service.DeleteBranch(ctx, ctx.Doer, 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, git_model.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 := issues_model.AddDeletePRBranchComment(ctx, ctx.Doer, pr.BaseRepo, pr.IssueID, 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) { + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.ServerError("GetPullRequestByIndex", err) + } + return + } + + binary := ctx.FormBool("binary") + + if err := pull_service.DownloadDiffOrPatch(ctx, pr, ctx, patch, binary); err != nil { + ctx.ServerError("DownloadDiffOrPatch", err) + return + } +} + +// UpdatePullRequestTarget change pull request's target branch +func UpdatePullRequestTarget(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + pr := issue.PullRequest + if !issue.IsPull { + ctx.Error(http.StatusNotFound) + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + targetBranch := ctx.FormTrim("target_branch") + if len(targetBranch) == 0 { + ctx.Error(http.StatusNoContent) + return + } + + if err := pull_service.ChangeTargetBranch(ctx, pr, ctx.Doer, targetBranch); err != nil { + if issues_model.IsErrPullRequestAlreadyExists(err) { + err := err.(issues_model.ErrPullRequestAlreadyExists) + + RepoRelPath := ctx.Repo.Owner.Name + "/" + ctx.Repo.Repository.Name + errorMessage := ctx.Tr("repo.pulls.has_pull_request", html.EscapeString(ctx.Repo.RepoLink+"/pulls/"+strconv.FormatInt(err.IssueID, 10)), html.EscapeString(RepoRelPath), err.IssueID) // FIXME: Creates url inside locale string + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]any{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if issues_model.IsErrIssueIsClosed(err) { + errorMessage := ctx.Tr("repo.pulls.is_closed") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]any{ + "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]any{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if git_model.IsErrBranchesEqual(err) { + errorMessage := ctx.Tr("repo.pulls.nothing_to_compare") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusBadRequest, map[string]any{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else { + ctx.ServerError("UpdatePullRequestTarget", err) + } + return + } + notify_service.PullRequestChangeTargetBranch(ctx, ctx.Doer, pr, targetBranch) + + ctx.JSON(http.StatusOK, map[string]any{ + "base_branch": pr.BaseBranch, + }) +} + +// SetAllowEdits allow edits from maintainers to PRs +func SetAllowEdits(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateAllowEditsForm) + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.ServerError("GetPullRequestByIndex", err) + } + return + } + + if err := pull_service.SetAllowEdits(ctx, ctx.Doer, pr, form.AllowMaintainerEdit); err != nil { + if errors.Is(err, pull_service.ErrUserHasNoPermissionForAction) { + ctx.Error(http.StatusForbidden) + return + } + ctx.ServerError("SetAllowEdits", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "allow_maintainer_edit": pr.AllowMaintainerEdit, + }) +} diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go new file mode 100644 index 0000000..e8a3c48 --- /dev/null +++ b/routers/web/repo/pull_review.go @@ -0,0 +1,316 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + pull_model "code.gitea.io/gitea/models/pull" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/forms" + pull_service "code.gitea.io/gitea/services/pull" +) + +const ( + tplDiffConversation base.TplName = "repo/diff/conversation" + tplTimelineConversation base.TplName = "repo/issue/view_content/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 ctx.Written() { + return + } + if !issue.IsPull { + return + } + currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue) + if err != nil && !issues_model.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.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + 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 ctx.Written() { + return + } + if !issue.IsPull { + 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 + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + comment, err := pull_service.CreateCodeComment(ctx, + ctx.Doer, + ctx.Repo.GitRepo, + issue, + signedLine, + form.Content, + form.TreePath, + !form.SingleReview, + form.Reply, + form.LatestCommitID, + attachments, + ) + 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) + + renderConversation(ctx, comment, form.Origin) +} + +// UpdateResolveConversation add or remove an Conversation resolved mark +func UpdateResolveConversation(ctx *context.Context) { + origin := ctx.FormString("origin") + action := ctx.FormString("action") + commentID := ctx.FormInt64("comment_id") + + comment, err := issues_model.GetCommentByID(ctx, commentID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if err = comment.LoadIssue(ctx); err != nil { + ctx.ServerError("comment.LoadIssue", err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("comment's repoID is incorrect", errors.New("comment's repoID is incorrect")) + return + } + + var permResult bool + if permResult, err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); 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 = issues_model.MarkConversation(ctx, comment, ctx.Doer, action == "Resolve") + if err != nil { + ctx.ServerError("MarkConversation", err) + return + } + } else { + ctx.Error(http.StatusBadRequest) + return + } + + renderConversation(ctx, comment, origin) +} + +func renderConversation(ctx *context.Context, comment *issues_model.Comment, origin string) { + comments, err := issues_model.FetchCodeConversation(ctx, comment, ctx.Doer) + if err != nil { + ctx.ServerError("FetchCodeCommentsByLine", err) + return + } + ctx.Data["PageIsPullFiles"] = (origin == "diff") + + if err := comments.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + ctx.Data["comments"] = comments + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + ctx.Data["Issue"] = comment.Issue + if err = comment.Issue.LoadPullRequest(ctx); 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 + if origin == "diff" { + ctx.HTML(http.StatusOK, tplDiffConversation) + } else if origin == "timeline" { + ctx.HTML(http.StatusOK, tplTimelineConversation) + } +} + +// 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 ctx.Written() { + return + } + if !issue.IsPull { + return + } + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + + reviewType := form.ReviewType() + switch reviewType { + case issues_model.ReviewTypeUnknown: + ctx.ServerError("ReviewType", fmt.Errorf("unknown ReviewType: %s", form.Type)) + return + + // can not approve/reject your own PR + case issues_model.ReviewTypeApprove, issues_model.ReviewTypeReject: + if issue.IsPoster(ctx.Doer.ID) { + var translated string + if reviewType == issues_model.ReviewTypeApprove { + translated = ctx.Locale.TrString("repo.issues.review.self.approval") + } else { + translated = ctx.Locale.TrString("repo.issues.review.self.rejection") + } + + ctx.Flash.Error(translated) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + _, comm, err := pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, issue, reviewType, form.Content, form.CommitID, attachments) + if err != nil { + if issues_model.IsContentEmptyErr(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty")) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + } else { + ctx.ServerError("SubmitReview", err) + } + return + } + ctx.JSONRedirect(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(ctx, form.ReviewID, ctx.Repo.Repository.ID, form.Message, ctx.Doer, true, true) + if err != nil { + if pull_service.IsErrDismissRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } + ctx.ServerError("pull_service.DismissReview", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, comm.Issue.Index, comm.HashTag())) +} + +// viewedFilesUpdate Struct to parse the body of a request to update the reviewed files of a PR +// If you want to implement an API to update the review, simply move this struct into modules. +type viewedFilesUpdate struct { + Files map[string]bool `json:"files"` + HeadCommitSHA string `json:"headCommitSHA"` +} + +func UpdateViewedFiles(ctx *context.Context) { + // Find corresponding PR + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + var data *viewedFilesUpdate + err := json.NewDecoder(ctx.Req.Body).Decode(&data) + if err != nil { + log.Warn("Attempted to update a review but could not parse request body: %v", err) + ctx.Resp.WriteHeader(http.StatusBadRequest) + return + } + + // Expect the review to have been now if no head commit was supplied + if data.HeadCommitSHA == "" { + data.HeadCommitSHA = pull.HeadCommitID + } + + updatedFiles := make(map[string]pull_model.ViewedState, len(data.Files)) + for file, viewed := range data.Files { + // Only unviewed and viewed are possible, has-changed can not be set from the outside + state := pull_model.Unviewed + if viewed { + state = pull_model.Viewed + } + updatedFiles[file] = state + } + + if err := pull_model.UpdateReviewState(ctx, ctx.Doer.ID, pull.ID, data.HeadCommitSHA, updatedFiles); err != nil { + ctx.ServerError("UpdateReview", err) + } +} diff --git a/routers/web/repo/pull_review_test.go b/routers/web/repo/pull_review_test.go new file mode 100644 index 0000000..329e83f --- /dev/null +++ b/routers/web/repo/pull_review_test.go @@ -0,0 +1,104 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/pull" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderConversation(t *testing.T) { + unittest.PrepareTestEnv(t) + + pr, _ := issues_model.GetPullRequestByID(db.DefaultContext, 2) + _ = pr.LoadIssue(db.DefaultContext) + _ = pr.Issue.LoadPoster(db.DefaultContext) + _ = pr.Issue.LoadRepo(db.DefaultContext) + + run := func(name string, cb func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder)) { + t.Run(name, func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "/") + ctx.Render = templates.HTMLRenderer() + contexttest.LoadUser(t, ctx, pr.Issue.PosterID) + contexttest.LoadRepo(t, ctx, pr.BaseRepoID) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + cb(t, ctx, resp) + }) + } + + var preparedComment *issues_model.Comment + run("prepare", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + comment, err := pull.CreateCodeComment(ctx, pr.Issue.Poster, ctx.Repo.GitRepo, pr.Issue, 1, "content", "", false, 0, pr.HeadCommitID, nil) + require.NoError(t, err) + + comment.Invalidated = true + err = issues_model.UpdateCommentInvalidate(ctx, comment) + require.NoError(t, err) + + preparedComment = comment + }) + if !assert.NotNil(t, preparedComment) { + return + } + run("diff with outdated", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + ctx.Data["ShowOutdatedComments"] = true + renderConversation(ctx, preparedComment, "diff") + assert.Contains(t, resp.Body.String(), `<div class="content comment-container"`) + }) + run("diff without outdated", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + ctx.Data["ShowOutdatedComments"] = false + renderConversation(ctx, preparedComment, "diff") + // unlike gitea, Forgejo renders the conversation (with the "outdated" label) + assert.Contains(t, resp.Body.String(), `repo.issues.review.outdated_description`) + }) + run("timeline with outdated", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + ctx.Data["ShowOutdatedComments"] = true + renderConversation(ctx, preparedComment, "timeline") + assert.Contains(t, resp.Body.String(), `<div id="code-comments-`) + }) + run("timeline is not affected by ShowOutdatedComments=false", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + ctx.Data["ShowOutdatedComments"] = false + renderConversation(ctx, preparedComment, "timeline") + assert.Contains(t, resp.Body.String(), `<div id="code-comments-`) + }) + run("diff non-existing review", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + reviews, err := issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{ + IssueID: 2, + }) + require.NoError(t, err) + for _, r := range reviews { + require.NoError(t, issues_model.DeleteReview(db.DefaultContext, r)) + } + ctx.Data["ShowOutdatedComments"] = true + renderConversation(ctx, preparedComment, "diff") + assert.Equal(t, http.StatusOK, resp.Code) + assert.NotContains(t, resp.Body.String(), `status-page-500`) + }) + run("timeline non-existing review", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + reviews, err := issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{ + IssueID: 2, + }) + require.NoError(t, err) + for _, r := range reviews { + require.NoError(t, issues_model.DeleteReview(db.DefaultContext, r)) + } + ctx.Data["ShowOutdatedComments"] = true + renderConversation(ctx, preparedComment, "timeline") + assert.Equal(t, http.StatusOK, resp.Code) + assert.NotContains(t, resp.Body.String(), `status-page-500`) + }) +} diff --git a/routers/web/repo/recent_commits.go b/routers/web/repo/recent_commits.go new file mode 100644 index 0000000..c158fb3 --- /dev/null +++ b/routers/web/repo/recent_commits.go @@ -0,0 +1,41 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" + contributors_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplRecentCommits base.TplName = "repo/activity" +) + +// RecentCommits renders the page to show recent commit frequency on repository +func RecentCommits(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.recent_commits") + + ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsRecentCommits"] = true + ctx.PageData["repoLink"] = ctx.Repo.RepoLink + + ctx.HTML(http.StatusOK, tplRecentCommits) +} + +// RecentCommitsData returns JSON of recent commits data +func RecentCommitsData(ctx *context.Context) { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if errors.Is(err, contributors_service.ErrAwaitGeneration) { + ctx.Status(http.StatusAccepted) + return + } + ctx.ServerError("RecentCommitsData", err) + } else { + ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) + } +} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go new file mode 100644 index 0000000..2266deb --- /dev/null +++ b/routers/web/repo/release.go @@ -0,0 +1,857 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/forms" + releaseservice "code.gitea.io/gitea/services/release" +) + +const ( + tplReleasesList base.TplName = "repo/release/list" + tplReleaseNew base.TplName = "repo/release/new" + tplTagsList base.TplName = "repo/tag/list" +) + +// calReleaseNumCommitsBehind calculates given release has how many commits behind release target. +func calReleaseNumCommitsBehind(repoCtx *context.Repository, release *repo_model.Release, countCache map[string]int64) error { + target := release.Target + if target == "" { + target = repoCtx.Repository.DefaultBranch + } + // Get count if not cached + if _, ok := countCache[target]; !ok { + commit, err := repoCtx.GitRepo.GetBranchCommit(target) + if err != nil { + var errNotExist git.ErrNotExist + if target == repoCtx.Repository.DefaultBranch || !errors.As(err, &errNotExist) { + return fmt.Errorf("GetBranchCommit: %w", err) + } + // fallback to default branch + target = repoCtx.Repository.DefaultBranch + commit, err = repoCtx.GitRepo.GetBranchCommit(target) + if err != nil { + return fmt.Errorf("GetBranchCommit(DefaultBranch): %w", err) + } + } + countCache[target], err = commit.CommitsCount() + if err != nil { + return fmt.Errorf("CommitsCount: %w", err) + } + } + release.NumCommitsBehind = countCache[target] - release.NumCommits + release.TargetBehind = target + return nil +} + +type ReleaseInfo struct { + Release *repo_model.Release + CommitStatus *git_model.CommitStatus + CommitStatuses []*git_model.CommitStatus +} + +func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) ([]*ReleaseInfo, error) { + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + return nil, err + } + + for _, release := range releases { + release.Repo = ctx.Repo.Repository + } + + if err = repo_model.GetReleaseAttachments(ctx, releases...); err != nil { + return nil, err + } + + // Temporary cache commits count of used branches to speed up. + countCache := make(map[string]int64) + cacheUsers := make(map[int64]*user_model.User) + if ctx.Doer != nil { + cacheUsers[ctx.Doer.ID] = ctx.Doer + } + var ok bool + + canReadActions := ctx.Repo.CanRead(unit.TypeActions) + + releaseInfos := make([]*ReleaseInfo, 0, len(releases)) + for _, r := range releases { + if r.Publisher, ok = cacheUsers[r.PublisherID]; !ok { + r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + r.Publisher = user_model.NewGhostUser() + } else { + return nil, err + } + } + cacheUsers[r.PublisherID] = r.Publisher + } + + r.RenderedNote, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, r.Note) + if err != nil { + return nil, err + } + + err = r.LoadArchiveDownloadCount(ctx) + if err != nil { + return nil, err + } + + if !r.IsDraft { + if err := calReleaseNumCommitsBehind(ctx.Repo, r, countCache); err != nil { + return nil, err + } + } + + info := &ReleaseInfo{ + Release: r, + } + + if canReadActions { + statuses, _, err := git_model.GetLatestCommitStatus(ctx, r.Repo.ID, r.Sha1, db.ListOptionsAll) + if err != nil { + return nil, err + } + + info.CommitStatus = git_model.CalcCommitStatus(statuses) + info.CommitStatuses = statuses + } + + releaseInfos = append(releaseInfos, info) + } + + return releaseInfos, nil +} + +// Releases render releases list page +func Releases(ctx *context.Context) { + ctx.Data["PageIsReleaseList"] = true + ctx.Data["Title"] = ctx.Tr("repo.release.releases") + 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 + + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: ctx.FormInt("limit"), + } + if listOptions.PageSize == 0 { + listOptions.PageSize = setting.Repository.Release.DefaultPagingNum + } + if listOptions.PageSize > setting.API.MaxResponseItems { + listOptions.PageSize = setting.API.MaxResponseItems + } + + writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) + ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived + + releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ + ListOptions: listOptions, + // only show draft releases for users who can write, read-only users shouldn't see draft releases. + IncludeDrafts: writeAccess, + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("getReleaseInfos", err) + return + } + for _, rel := range releases { + if rel.Release.IsTag && rel.Release.Title == "" { + rel.Release.Title = rel.Release.TagName + } + } + + ctx.Data["Releases"] = releases + addVerifyTagToContext(ctx) + + numReleases := ctx.Data["NumReleases"].(int64) + pager := context.NewPagination(int(numReleases), listOptions.PageSize, listOptions.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplReleasesList) +} + +func verifyTagSignature(ctx *context.Context, r *repo_model.Release) (*asymkey.ObjectVerification, error) { + if err := r.LoadAttributes(ctx); err != nil { + return nil, err + } + gitRepo, err := gitrepo.OpenRepository(ctx, r.Repo) + if err != nil { + return nil, err + } + defer gitRepo.Close() + + tag, err := gitRepo.GetTag(r.TagName) + if err != nil { + return nil, err + } + if tag.Signature == nil { + return nil, nil + } + + verification := asymkey.ParseTagWithSignature(ctx, gitRepo, tag) + return verification, nil +} + +func addVerifyTagToContext(ctx *context.Context) { + ctx.Data["VerifyTag"] = func(r *repo_model.Release) *asymkey.ObjectVerification { + v, err := verifyTagSignature(ctx, r) + if err != nil { + return nil + } + return v + } + ctx.Data["HasSignature"] = func(verification *asymkey.ObjectVerification) bool { + if verification == nil { + return false + } + return verification.Reason != "gpg.error.not_signed_commit" + } +} + +// TagsList render tags list page +func TagsList(ctx *context.Context) { + ctx.Data["PageIsTagList"] = true + ctx.Data["Title"] = ctx.Tr("repo.release.tags") + 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 + ctx.Data["CanCreateRelease"] = ctx.Repo.CanWrite(unit.TypeReleases) && !ctx.Repo.Repository.IsArchived + + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: ctx.FormInt("limit"), + } + if listOptions.PageSize == 0 { + listOptions.PageSize = setting.Repository.Release.DefaultPagingNum + } + if listOptions.PageSize > setting.API.MaxResponseItems { + listOptions.PageSize = setting.API.MaxResponseItems + } + + opts := repo_model.FindReleasesOptions{ + ListOptions: listOptions, + // for the tags list page, show all releases with real tags (having real commit-id), + // the drafts should also be included because a real tag might be used as a draft. + IncludeDrafts: true, + IncludeTags: true, + HasSha1: optional.Some(true), + RepoID: ctx.Repo.Repository.ID, + } + + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + ctx.ServerError("GetReleasesByRepoID", err) + return + } + + ctx.Data["Releases"] = releases + addVerifyTagToContext(ctx) + + numTags := ctx.Data["NumTags"].(int64) + pager := context.NewPagination(int(numTags), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.Data["PageIsViewCode"] = !ctx.Repo.Repository.UnitEnabled(ctx, unit.TypeReleases) + ctx.HTML(http.StatusOK, tplTagsList) +} + +// ReleasesFeedRSS get feeds for releases in RSS format +func ReleasesFeedRSS(ctx *context.Context) { + releasesOrTagsFeed(ctx, true, "rss") +} + +// TagsListFeedRSS get feeds for tags in RSS format +func TagsListFeedRSS(ctx *context.Context) { + releasesOrTagsFeed(ctx, false, "rss") +} + +// ReleasesFeedAtom get feeds for releases in Atom format +func ReleasesFeedAtom(ctx *context.Context) { + releasesOrTagsFeed(ctx, true, "atom") +} + +// TagsListFeedAtom get feeds for tags in RSS format +func TagsListFeedAtom(ctx *context.Context) { + releasesOrTagsFeed(ctx, false, "atom") +} + +func releasesOrTagsFeed(ctx *context.Context, isReleasesOnly bool, formatType string) { + feed.ShowReleaseFeed(ctx, ctx.Repo.Repository, isReleasesOnly, formatType) +} + +// SingleRelease renders a single release's page +func SingleRelease(ctx *context.Context) { + ctx.Data["PageIsReleaseList"] = true + ctx.Data["DefaultBranch"] = ctx.Repo.Repository.DefaultBranch + + writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) + ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived + + releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ + ListOptions: db.ListOptions{Page: 1, PageSize: 1}, + RepoID: ctx.Repo.Repository.ID, + // Include tags in the search too. + IncludeTags: true, + TagNames: []string{ctx.Params("*")}, + // only show draft releases for users who can write, read-only users shouldn't see draft releases. + IncludeDrafts: writeAccess, + }) + if err != nil { + ctx.ServerError("getReleaseInfos", err) + return + } + if len(releases) != 1 { + ctx.NotFound("SingleRelease", err) + return + } + + release := releases[0].Release + if release.IsTag && release.Title == "" { + release.Title = release.TagName + } + addVerifyTagToContext(ctx) + + ctx.Data["PageIsSingleTag"] = release.IsTag + if release.IsTag { + ctx.Data["Title"] = release.TagName + } else { + ctx.Data["Title"] = release.Title + } + + err = release.LoadArchiveDownloadCount(ctx) + if err != nil { + ctx.ServerError("LoadArchiveDownloadCount", err) + return + } + + ctx.Data["Releases"] = releases + ctx.HTML(http.StatusOK, tplReleasesList) +} + +// LatestRelease redirects to the latest release +func LatestRelease(ctx *context.Context) { + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound("LatestRelease", err) + return + } + ctx.ServerError("GetLatestReleaseByRepoID", err) + return + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + ctx.Redirect(release.Link()) +} + +// 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["tag_target"] = ctx.Repo.Repository.DefaultBranch + if tagName := ctx.FormString("tag"); len(tagName) > 0 { + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.ServerError("GetRelease", err) + return + } + + if rel != nil { + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + ctx.Data["tag_name"] = rel.TagName + if rel.Target != "" { + 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 + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + upload.AddUploadContext(ctx, "release") + + // For New Release page + PrepareBranchList(ctx) + if ctx.Written() { + return + } + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + // We set the value of the hide_archive_link textbox depending on the latest release + latestRelease, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.Data["hide_archive_links"] = false + } else { + ctx.ServerError("GetLatestReleaseByRepoID", err) + return + } + } + if latestRelease != nil { + ctx.Data["hide_archive_links"] = latestRelease.HideArchiveLinks + } + + 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 + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplReleaseNew) + return + } + + objectFormat, _ := ctx.Repo.GitRepo.GetObjectFormat() + + // form.Target can be a branch name or a full commitID. + if !ctx.Repo.GitRepo.IsBranchExist(form.Target) && + len(form.Target) == objectFormat.FullLength() && !ctx.Repo.GitRepo.IsCommitExist(form.Target) { + ctx.RenderWithErr(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form) + return + } + + // Title of release cannot be empty + if len(form.TagOnly) == 0 && len(form.Title) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.release.title_empty"), tplReleaseNew, &form) + return + } + + attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) + attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) + + if setting.Attachment.Enabled { + for _, uuid := range form.Files { + attachmentChanges.Add(&releaseservice.AttachmentChange{ + Action: "add", + Type: "attachment", + UUID: uuid, + }) + } + + const namePrefix = "attachment-new-name-" + const exturlPrefix = "attachment-new-exturl-" + for k, v := range ctx.Req.Form { + isNewName := strings.HasPrefix(k, namePrefix) + isNewExturl := strings.HasPrefix(k, exturlPrefix) + if isNewName || isNewExturl { + var id string + if isNewName { + id = k[len(namePrefix):] + } else if isNewExturl { + id = k[len(exturlPrefix):] + } + if _, ok := attachmentChangesByID[id]; !ok { + attachmentChangesByID[id] = &releaseservice.AttachmentChange{ + Action: "add", + Type: "external", + } + attachmentChanges.Add(attachmentChangesByID[id]) + } + if isNewName { + attachmentChangesByID[id].Name = v[0] + } else if isNewExturl { + attachmentChangesByID[id].ExternalURL = v[0] + } + } + } + } + + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName) + if err != nil { + if !repo_model.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, ctx.Doer, 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 + } + + if models.IsErrInvalidTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_invalid")) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + if models.IsErrProtectedTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) + 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/" + util.PathEscapeSegments(form.TagName)) + return + } + + rel = &repo_model.Release{ + RepoID: ctx.Repo.Repository.ID, + Repo: ctx.Repo.Repository, + PublisherID: ctx.Doer.ID, + Publisher: ctx.Doer, + Title: form.Title, + TagName: form.TagName, + Target: form.Target, + Note: form.Content, + IsDraft: len(form.Draft) > 0, + IsPrerelease: form.Prerelease, + HideArchiveLinks: form.HideArchiveLinks, + IsTag: false, + } + + if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, msg, attachmentChanges.Values()); err != nil { + ctx.Data["Err_TagName"] = true + switch { + case repo_model.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) + case models.IsErrProtectedTagName(err): + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form) + case repo_model.IsErrInvalidExternalURL(err): + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), 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.Doer.ID + rel.HideArchiveLinks = form.HideArchiveLinks + rel.IsTag = false + + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, attachmentChanges.Values()); err != nil { + ctx.Data["Err_TagName"] = true + switch { + case repo_model.IsErrInvalidExternalURL(err): + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) + default: + ctx.ServerError("UpdateRelease", err) + } + return + } + } + log.Trace("Release created: %s/%s:%s", ctx.Doer.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["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "release") + + tagName := ctx.Params("*") + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) + if err != nil { + if repo_model.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["hide_archive_links"] = rel.HideArchiveLinks + ctx.Data["IsDraft"] = rel.IsDraft + + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Data["attachments"] = rel.Attachments + + // Get assignees. + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, rel.Repo) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + 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 + + tagName := ctx.Params("*") + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) + if err != nil { + if repo_model.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 + ctx.Data["hide_archive_links"] = rel.HideArchiveLinks + + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + // TODO: If an error occurs, do not forget the attachment edits the user made + // when displaying the error message. + ctx.Data["attachments"] = rel.Attachments + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplReleaseNew) + return + } + + const delPrefix = "attachment-del-" + const editPrefix = "attachment-edit-" + const newPrefix = "attachment-new-" + const namePrefix = "name-" + const exturlPrefix = "exturl-" + attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) + attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) + + if setting.Attachment.Enabled { + for _, uuid := range form.Files { + attachmentChanges.Add(&releaseservice.AttachmentChange{ + Action: "add", + Type: "attachment", + UUID: uuid, + }) + } + + for k, v := range ctx.Req.Form { + if strings.HasPrefix(k, delPrefix) && v[0] == "true" { + attachmentChanges.Add(&releaseservice.AttachmentChange{ + Action: "delete", + UUID: k[len(delPrefix):], + }) + } else { + isUpdatedName := strings.HasPrefix(k, editPrefix+namePrefix) + isUpdatedExturl := strings.HasPrefix(k, editPrefix+exturlPrefix) + isNewName := strings.HasPrefix(k, newPrefix+namePrefix) + isNewExturl := strings.HasPrefix(k, newPrefix+exturlPrefix) + + if isUpdatedName || isUpdatedExturl || isNewName || isNewExturl { + var uuid string + + if isUpdatedName { + uuid = k[len(editPrefix+namePrefix):] + } else if isUpdatedExturl { + uuid = k[len(editPrefix+exturlPrefix):] + } else if isNewName { + uuid = k[len(newPrefix+namePrefix):] + } else if isNewExturl { + uuid = k[len(newPrefix+exturlPrefix):] + } + + if _, ok := attachmentChangesByID[uuid]; !ok { + attachmentChangesByID[uuid] = &releaseservice.AttachmentChange{ + Type: "attachment", + UUID: uuid, + } + attachmentChanges.Add(attachmentChangesByID[uuid]) + } + + if isUpdatedName || isUpdatedExturl { + attachmentChangesByID[uuid].Action = "update" + } else if isNewName || isNewExturl { + attachmentChangesByID[uuid].Action = "add" + } + + if isUpdatedName || isNewName { + attachmentChangesByID[uuid].Name = v[0] + } else if isUpdatedExturl || isNewExturl { + attachmentChangesByID[uuid].ExternalURL = v[0] + attachmentChangesByID[uuid].Type = "external" + } + } + } + } + } + + rel.Title = form.Title + rel.Note = form.Content + rel.IsDraft = len(form.Draft) > 0 + rel.IsPrerelease = form.Prerelease + rel.HideArchiveLinks = form.HideArchiveLinks + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, attachmentChanges.Values()); err != nil { + switch { + case repo_model.IsErrInvalidExternalURL(err): + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) + default: + ctx.ServerError("UpdateRelease", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/releases") +} + +// DeleteRelease deletes a release +func DeleteRelease(ctx *context.Context) { + deleteReleaseOrTag(ctx, false) +} + +// DeleteTag deletes a tag +func DeleteTag(ctx *context.Context) { + deleteReleaseOrTag(ctx, true) +} + +func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { + redirect := func() { + if isDelTag { + ctx.JSONRedirect(ctx.Repo.RepoLink + "/tags") + return + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases") + } + + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound("GetReleaseForRepoByID", err) + } else { + ctx.Flash.Error("DeleteReleaseByID: " + err.Error()) + redirect() + } + return + } + + if err := releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil { + if models.IsErrProtectedTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) + } else { + 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")) + } + } + + redirect() +} diff --git a/routers/web/repo/release_test.go b/routers/web/repo/release_test.go new file mode 100644 index 0000000..5c7b6e2 --- /dev/null +++ b/routers/web/repo/release_test.go @@ -0,0 +1,124 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func 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", + }, + }, + } { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadGitRepo(t, ctx) + web.SetForm(ctx, &testCase.Form) + NewReleasePost(ctx) + unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ + RepoID: 1, + PublisherID: 2, + TagName: testCase.Form.TagName, + Target: testCase.Form.Target, + Title: testCase.Form.Title, + Note: testCase.Form.Content, + }, unittest.Cond("is_draft=?", len(testCase.Form.Draft) > 0)) + ctx.Repo.GitRepo.Close() + } +} + +func TestCalReleaseNumCommitsBehind(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo-release/releases") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 57) + contexttest.LoadGitRepo(t, ctx) + t.Cleanup(func() { ctx.Repo.GitRepo.Close() }) + + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + RepoID: ctx.Repo.Repository.ID, + }) + require.NoError(t, err) + + countCache := make(map[string]int64) + for _, release := range releases { + err := calReleaseNumCommitsBehind(ctx.Repo, release, countCache) + require.NoError(t, err) + } + + type computedFields struct { + NumCommitsBehind int64 + TargetBehind string + } + expectedComputation := map[string]computedFields{ + "v1.0": { + NumCommitsBehind: 3, + TargetBehind: "main", + }, + "v1.1": { + NumCommitsBehind: 1, + TargetBehind: "main", + }, + "v2.0": { + NumCommitsBehind: 0, + TargetBehind: "main", + }, + "non-existing-target-branch": { + NumCommitsBehind: 1, + TargetBehind: "main", + }, + "empty-target-branch": { + NumCommitsBehind: 1, + TargetBehind: "main", + }, + } + for _, r := range releases { + actual := computedFields{ + NumCommitsBehind: r.NumCommitsBehind, + TargetBehind: r.TargetBehind, + } + assert.Equal(t, expectedComputation[r.TagName], actual, "wrong computed fields for %s: %#v", r.TagName, r) + } +} diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go new file mode 100644 index 0000000..e64db03 --- /dev/null +++ b/routers/web/repo/render.go @@ -0,0 +1,76 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "io" + "net/http" + "path" + + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// RenderFile renders a file by repos path +func RenderFile(ctx *context.Context) { + blob, err := ctx.Repo.Commit.GetBlobByPath(ctx.Repo.TreePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlobByPath", err) + } else { + ctx.ServerError("GetBlobByPath", err) + } + return + } + + dataRc, err := blob.DataAsync() + if err != nil { + ctx.ServerError("DataAsync", err) + return + } + defer dataRc.Close() + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(dataRc, buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + isTextFile := st.IsText() + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts") + + if markupType := markup.Type(blob.Name()); markupType == "" { + if isTextFile { + _, _ = io.Copy(ctx.Resp, rd) + } else { + http.Error(ctx.Resp, "Unsupported file type render", http.StatusInternalServerError) + } + return + } + + err = markup.Render(&markup.RenderContext{ + Ctx: ctx, + RelativePath: ctx.Repo.TreePath, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + InStandalonePage: true, + }, rd, ctx.Resp) + if err != nil { + log.Error("Failed to render file %q: %v", ctx.Repo.TreePath, err) + http.Error(ctx.Resp, "Failed to render file", http.StatusInternalServerError) + return + } +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go new file mode 100644 index 0000000..9562491 --- /dev/null +++ b/routers/web/repo/repo.go @@ -0,0 +1,774 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" + archiver_service "code.gitea.io/gitea/services/repository/archiver" + commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" +) + +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 CommitInfoCache(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 + ctx.Repo.GitRepo.LastCommitCache = git.NewLastCommitCache(ctx.Repo.CommitsCount, ctx.Repo.Repository.FullName(), ctx.Repo.GitRepo, cache.GetCache()) +} + +func checkContextUser(ctx *context.Context, uid int64) *user_model.User { + orgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return nil + } + + if !ctx.Doer.IsAdmin { + orgsAvailable := []*organization.Organization{} + 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.Doer.ID || uid == 0 { + return ctx.Doer + } + + org, err := user_model.GetUserByID(ctx, uid) + if user_model.IsErrUserNotExist(err) { + return ctx.Doer + } + + if err != nil { + ctx.ServerError("GetUserByID", fmt.Errorf("[%d]: %w", uid, err)) + return nil + } + + // Check ownership of organization. + if !org.IsOrganization() { + ctx.Error(http.StatusForbidden) + return nil + } + if !ctx.Doer.IsAdmin { + canCreate, err := organization.OrgFromUser(org).CanCreateOrgRepo(ctx, ctx.Doer.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.Doer.LastRepoVisibility + case setting.RepoCreatingPrivate: + return true + case setting.RepoCreatingPublic: + return false + default: + return ctx.Doer.LastRepoVisibility + } +} + +// Create render creating repository page +func Create(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_repo.title") + + // Give default value for template to render. + ctx.Data["Gitignores"] = repo_module.Gitignores + ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + ctx.Data["Licenses"] = repo_module.Licenses + ctx.Data["Readmes"] = repo_module.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.FormInt64("org")) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + ctx.Data["repo_template_name"] = ctx.Tr("repo.template_select") + templateID := ctx.FormInt64("template_id") + if templateID > 0 { + templateRepo, err := repo_model.GetRepositoryByID(ctx, templateID) + if err == nil && access_model.CheckRepoUnitUser(ctx, templateRepo, ctxUser, unit.TypeCode) { + ctx.Data["repo_template"] = templateID + ctx.Data["repo_template_name"] = templateRepo.Name + } + } + + ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo() + ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit() + ctx.Data["SupportedObjectFormats"] = git.SupportedObjectFormats + ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat + + ctx.HTML(http.StatusOK, tplCreate) +} + +func handleCreateError(ctx *context.Context, owner *user_model.User, err error, name string, tpl base.TplName, form any) { + switch { + case repo_model.IsErrReachLimitOfRepo(err): + maxCreationLimit := owner.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.RenderWithErr(msg, tpl, form) + case repo_model.IsErrRepoAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) + case repo_model.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 db.IsErrNameReserved(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.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.title") + + ctx.Data["Gitignores"] = repo_module.Gitignores + ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + ctx.Data["Licenses"] = repo_module.Licenses + ctx.Data["Readmes"] = repo_module.Readmes + + ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo() + ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit() + ctx.Data["SupportedObjectFormats"] = git.SupportedObjectFormats + ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplCreate) + return + } + + var repo *repo_model.Repository + var err error + if form.RepoTemplate > 0 { + opts := repo_service.GenerateRepoOptions{ + Name: form.RepoName, + Description: form.Description, + Private: form.Private || setting.Repository.ForcePrivate, + GitContent: form.GitContent, + Topics: form.Topics, + GitHooks: form.GitHooks, + Webhooks: form.Webhooks, + Avatar: form.Avatar, + IssueLabels: form.Labels, + ProtectedBranch: form.ProtectedBranch, + } + + 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, ctx.Doer, ctxUser, templateRepo, opts) + if err == nil { + log.Trace("Repository generated [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(repo.Link()) + return + } + } else { + repo, err = repo_service.CreateRepository(ctx, ctx.Doer, ctxUser, repo_service.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: repo_model.DefaultTrustModel, + ObjectFormatName: form.ObjectFormatName, + }) + if err == nil { + log.Trace("Repository created [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(repo.Link()) + return + } + } + + handleCreateError(ctx, ctxUser, err, "CreatePost", tplCreate, &form) +} + +const ( + tplWatchUnwatch base.TplName = "repo/watch_unwatch" + tplStarUnstar base.TplName = "repo/star_unstar" +) + +func ActionWatch(watch bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, watch) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (watch, %t)", watch), err) + return + } + + ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + + // we have to reload the repository because NumStars or NumWatching (used in the templates) has just changed + ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (watch, %t)", watch), err) + return + } + + ctx.HTML(http.StatusOK, tplWatchUnwatch) + } +} + +func ActionStar(star bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + err := repo_service.StarRepoAndSendLikeActivities(ctx, *ctx.Doer, ctx.Repo.Repository.ID, star) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (star, %t)", star), err) + return + } + + ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + + // we have to reload the repository because NumStars or NumWatching (used in the templates) has just changed + ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (star, %t)", star), err) + return + } + + ctx.HTML(http.StatusOK, tplStarUnstar) + } +} + +func ActionTransfer(accept bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + var action string + if accept { + action = "accept_transfer" + } else { + action = "reject_transfer" + } + + ok, err := acceptOrRejectRepoTransfer(ctx, accept) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", action), err) + return + } + if !ok { + return + } + + ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.Repo.RepoLink) + } +} + +func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) (bool, error) { + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) + if err != nil { + return false, err + } + + if err := repoTransfer.LoadAttributes(ctx); err != nil { + return false, err + } + + if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) { + return false, errors.New("user does not have enough permissions") + } + + if accept { + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctx.Doer.ID, ctx.Doer.Name) { + return false, nil + } + + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + + if err := repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { + return false, err + } + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) + } else { + if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { + return false, err + } + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) + } + + ctx.Redirect(ctx.Repo.Repository.Link()) + return true, 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 := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + RepoID: curRepo.ID, + TagNames: tagNames, + }) + if err != nil { + ctx.ServerError("RedirectDownload", err) + return + } + if len(releases) == 1 { + release := releases[0] + att, err := repo_model.GetAttachmentByReleaseIDFileName(ctx, release.ID, fileName) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + if att != nil { + ServeAttachment(ctx, att.UUID) + return + } + } else if len(releases) == 0 && vTag == "latest" { + // GitHub supports the alias "latest" for the latest release + // We only fetch the latest release if the tag is "latest" and no release with the tag "latest" exists + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + att, err := repo_model.GetAttachmentByReleaseIDFileName(ctx, release.ID, fileName) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + if att != nil { + ServeAttachment(ctx, att.UUID) + return + } + } + ctx.Error(http.StatusNotFound) +} + +// Download an archive of a repository +func Download(ctx *context.Context) { + uri := ctx.Params("*") + aReq, err := archiver_service.NewRequest(ctx, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + if err != nil { + if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { + ctx.Error(http.StatusBadRequest, err.Error()) + } else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) { + ctx.Error(http.StatusNotFound, err.Error()) + } else { + ctx.ServerError("archiver_service.NewRequest", err) + } + return + } + + archiver, err := aReq.Await(ctx) + if err != nil { + ctx.ServerError("archiver.Await", err) + return + } + + download(ctx, aReq.GetArchiveName(), archiver) +} + +func download(ctx *context.Context, archiveName string, archiver *repo_model.RepoArchiver) { + downloadName := ctx.Repo.Repository.Name + "-" + archiveName + + // Add nix format link header so tarballs lock correctly: + // https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md + ctx.Resp.Header().Add("Link", fmt.Sprintf("<%s/archive/%s.tar.gz?rev=%s>; rel=\"immutable\"", + ctx.Repo.Repository.APIURL(), + archiver.CommitID, archiver.CommitID)) + + rPath := archiver.RelativePath() + if setting.RepoArchive.Storage.MinioConfig.ServeDirect { + // If we have a signed url (S3, object storage), redirect to this directly. + u, err := storage.RepoArchives.URL(rPath, downloadName) + if u != nil && err == nil { + if archiver.ReleaseID != 0 { + err = repo_model.CountArchiveDownload(ctx, ctx.Repo.Repository.ID, archiver.ReleaseID, archiver.Type) + if err != nil { + ctx.ServerError("CountArchiveDownload", err) + return + } + } + + ctx.Redirect(u.String()) + return + } + } + + // If we have matched and access to release or issue + fr, err := storage.RepoArchives.Open(rPath) + if err != nil { + ctx.ServerError("Open", err) + return + } + defer fr.Close() + + if archiver.ReleaseID != 0 { + err = repo_model.CountArchiveDownload(ctx, ctx.Repo.Repository.ID, archiver.ReleaseID, archiver.Type) + if err != nil { + ctx.ServerError("CountArchiveDownload", err) + return + } + } + + ctx.ServeContent(fr, &context.ServeHeaderOptions{ + Filename: downloadName, + LastModified: archiver.CreatedUnix.AsLocalTime(), + }) +} + +// 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, err := archiver_service.NewRequest(ctx, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + if err != nil { + ctx.ServerError("archiver_service.NewRequest", err) + return + } + if aReq == nil { + ctx.Error(http.StatusNotFound) + return + } + + archiver, err := repo_model.GetRepoArchiver(ctx, aReq.RepoID, aReq.Type, aReq.CommitID) + if err != nil { + ctx.ServerError("archiver_service.StartArchive", err) + return + } + if archiver == nil || archiver.Status != repo_model.ArchiverReady { + if err := archiver_service.StartArchive(aReq); err != nil { + ctx.ServerError("archiver_service.StartArchive", err) + return + } + } + + var completed bool + if archiver != nil && archiver.Status == repo_model.ArchiverReady { + completed = true + } + + ctx.JSON(http.StatusOK, map[string]any{ + "complete": completed, + }) +} + +// SearchRepo repositories via options +func SearchRepo(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + opts := &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + OwnerID: ctx.FormInt64("uid"), + PriorityOwnerID: ctx.FormInt64("priority_owner_id"), + TeamID: ctx.FormInt64("team_id"), + TopicOnly: ctx.FormBool("topic"), + Collaborate: optional.None[bool](), + Private: ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")), + Template: optional.None[bool](), + StarredByID: ctx.FormInt64("starredBy"), + IncludeDescription: ctx.FormBool("includeDesc"), + } + + if ctx.FormString("template") != "" { + opts.Template = optional.Some(ctx.FormBool("template")) + } + + if ctx.FormBool("exclusive") { + opts.Collaborate = optional.Some(false) + } + + mode := ctx.FormString("mode") + switch mode { + case "source": + opts.Fork = optional.Some(false) + opts.Mirror = optional.Some(false) + case "fork": + opts.Fork = optional.Some(true) + case "mirror": + opts.Mirror = optional.Some(true) + case "collaborative": + opts.Mirror = optional.Some(false) + opts.Collaborate = optional.Some(true) + case "": + default: + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid search mode: \"%s\"", mode)) + return + } + + if ctx.FormString("archived") != "" { + opts.Archived = optional.Some(ctx.FormBool("archived")) + } + + if ctx.FormString("is_private") != "" { + opts.IsPrivate = optional.Some(ctx.FormBool("is_private")) + } + + sortMode := ctx.FormString("sort") + if len(sortMode) > 0 { + sortOrder := ctx.FormString("order") + if len(sortOrder) == 0 { + sortOrder = "asc" + } + if searchModeMap, ok := repo_model.OrderByMap[sortOrder]; ok { + if orderBy, ok := searchModeMap[sortMode]; ok { + opts.OrderBy = orderBy + } else { + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort mode: \"%s\"", sortMode)) + return + } + } else { + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort order: \"%s\"", sortOrder)) + return + } + } + + // To improve performance when only the count is requested + if ctx.FormBool("count_only") { + if count, err := repo_model.CountRepository(ctx, opts); err != nil { + log.Error("CountRepository: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) // frontend JS doesn't handle error response (same as below) + } else { + ctx.SetTotalCountHeader(count) + ctx.JSONOK() + } + return + } + + repos, count, err := repo_model.SearchRepository(ctx, opts) + if err != nil { + log.Error("SearchRepository: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) + return + } + + ctx.SetTotalCountHeader(count) + + latestCommitStatuses, err := commitstatus_service.FindReposLastestCommitStatuses(ctx, repos) + if err != nil { + log.Error("FindReposLastestCommitStatuses: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, latestCommitStatuses) + } + + results := make([]*repo_service.WebSearchRepository, len(repos)) + for i, repo := range repos { + results[i] = &repo_service.WebSearchRepository{ + Repository: &api.Repository{ + ID: repo.ID, + FullName: repo.FullName(), + Fork: repo.IsFork, + Private: repo.IsPrivate, + Template: repo.IsTemplate, + Mirror: repo.IsMirror, + Stars: repo.NumStars, + HTMLURL: repo.HTMLURL(), + Link: repo.Link(), + Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, + }, + } + + if latestCommitStatuses[i] != nil { + results[i].LatestCommitStatus = latestCommitStatuses[i] + results[i].LocaleLatestCommitStatus = latestCommitStatuses[i].LocaleString(ctx.Locale) + } + } + + ctx.JSON(http.StatusOK, repo_service.WebSearchResults{ + OK: true, + Data: results, + }) +} + +type branchTagSearchResponse struct { + Results []string `json:"results"` +} + +// GetBranchesList get branches for current repo' +func GetBranchesList(ctx *context.Context) { + branchOpts := git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptionsAll, + } + branches, err := git_model.FindBranchNames(ctx, branchOpts) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + resp := &branchTagSearchResponse{} + // always put default branch on the top if it exists + if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) { + branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch) + branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) + } + resp.Results = branches + ctx.JSON(http.StatusOK, resp) +} + +// GetTagList get tag list for current repo +func GetTagList(ctx *context.Context) { + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + resp := &branchTagSearchResponse{} + resp.Results = tags + ctx.JSON(http.StatusOK, resp) +} + +func PrepareBranchList(ctx *context.Context) { + branchOpts := git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptionsAll, + } + brs, err := git_model.FindBranchNames(ctx, branchOpts) + if err != nil { + ctx.ServerError("GetBranches", err) + return + } + // always put default branch on the top if it exists + if slices.Contains(brs, ctx.Repo.Repository.DefaultBranch) { + brs = util.SliceRemoveAll(brs, ctx.Repo.Repository.DefaultBranch) + brs = append([]string{ctx.Repo.Repository.DefaultBranch}, brs...) + } + ctx.Data["Branches"] = brs +} diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go new file mode 100644 index 0000000..c4f9f9a --- /dev/null +++ b/routers/web/repo/search.go @@ -0,0 +1,105 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const tplSearch base.TplName = "repo/search" + +// Search render repository search page +func Search(ctx *context.Context) { + language := ctx.FormTrim("l") + keyword := ctx.FormTrim("q") + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["PageIsViewCode"] = true + + if keyword == "" { + ctx.HTML(http.StatusOK, tplSearch) + return + } + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + var total int + var searchResults []*code_indexer.Result + var searchResultLanguages []*code_indexer.SearchResultLanguages + if setting.Indexer.RepoIndexerEnabled { + var err error + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: []int64{ctx.Repo.Repository.ID}, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["CodeIndexerUnavailable"] = true + } else { + ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + } + } else { + res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ + ContextLineNumber: 1, + IsFuzzy: isFuzzy, + RefName: ctx.Repo.RefName, + }) + if err != nil { + ctx.ServerError("GrepSearch", err) + return + } + total = len(res) + pageStart := min((page-1)*setting.UI.RepoSearchPagingNum, len(res)) + pageEnd := min(page*setting.UI.RepoSearchPagingNum, len(res)) + res = res[pageStart:pageEnd] + for _, r := range res { + searchResults = append(searchResults, &code_indexer.Result{ + RepoID: ctx.Repo.Repository.ID, + Filename: r.Filename, + CommitID: ctx.Repo.CommitID, + // UpdatedUnix: not supported yet + // Language: not supported yet + // Color: not supported yet + Lines: code_indexer.HighlightSearchResultCode(r.Filename, r.LineNumbers, r.HighlightedRanges, strings.Join(r.LineCodes, "\n")), + }) + } + } + + ctx.Data["CodeIndexerDisabled"] = !setting.Indexer.RepoIndexerEnabled + ctx.Data["Repo"] = ctx.Repo.Repository + ctx.Data["SourcePath"] = ctx.Repo.Repository.Link() + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + + 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/avatar.go b/routers/web/repo/setting/avatar.go new file mode 100644 index 0000000..504f57c --- /dev/null +++ b/routers/web/repo/setting/avatar.go @@ -0,0 +1,76 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "io" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" +) + +// 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: %w", err) + } + defer r.Close() + + if form.Avatar.Size > setting.Avatar.MaxFileSize { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) + } + + data, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("io.ReadAll: %w", err) + } + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image")) + } + if err = repo_service.UploadAvatar(ctx, ctxRepo, data); err != nil { + return fmt.Errorf("UploadAvatar: %w", 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 := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository); err != nil { + ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err)) + } + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings") +} diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go new file mode 100644 index 0000000..75b5515 --- /dev/null +++ b/routers/web/repo/setting/collaboration.go @@ -0,0 +1,217 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" +) + +// Collaboration render a repository's collaboration page +func Collaboration(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.collaboration") + ctx.Data["PageIsSettingsCollaboration"] = true + + users, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("GetCollaborators", err) + return + } + ctx.Data["Collaborators"] = users + + teams, err := organization.GetRepoTeams(ctx, ctx.Repo.Repository) + 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"] = unit_model.Units + + ctx.HTML(http.StatusOK, tplCollaboration) +} + +// CollaborationPost response for actions for a collaboration of a repository +func CollaborationPost(ctx *context.Context) { + name := strings.ToLower(ctx.FormString("collaborator")) + if len(name) == 0 || ctx.Repo.Owner.LowerName == name { + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + return + } + + u, err := user_model.GetUserByName(ctx, name) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if !u.IsActive { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_inactive_user")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + 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.EscapedPath()) + return + } + + if got, err := repo_model.IsCollaborator(ctx, ctx.Repo.Repository.ID, u.ID); err == nil && got { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_duplicate")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + // find the owner team of the organization the repo belongs too and + // check if the user we're trying to add is an owner. + if ctx.Repo.Repository.Owner.IsOrganization() { + if isOwner, err := organization.IsOrganizationOwner(ctx, ctx.Repo.Repository.Owner.ID, u.ID); err != nil { + ctx.ServerError("IsOrganizationOwner", err) + return + } else if isOwner { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_owner")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + return + } + } + + if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil { + if !errors.Is(err, user_model.ErrBlockedByUser) { + ctx.ServerError("AddCollaborator", err) + return + } + + // To give an good error message, be precise on who has blocked who. + if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our")) + } else { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them")) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + if setting.Service.EnableNotifyMail { + mailer.SendCollaboratorMail(u, ctx.Doer, ctx.Repo.Repository) + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_collaborator_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) +} + +// ChangeCollaborationAccessMode response for changing access of a collaboration +func ChangeCollaborationAccessMode(ctx *context.Context) { + if err := repo_model.ChangeCollaborationAccessMode( + ctx, + ctx.Repo.Repository, + ctx.FormInt64("uid"), + perm.AccessMode(ctx.FormInt("mode"))); err != nil { + log.Error("ChangeCollaborationAccessMode: %v", err) + } +} + +// DeleteCollaboration delete a collaboration for a repository +func DeleteCollaboration(ctx *context.Context) { + if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteCollaboration: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) + } + + ctx.JSONRedirect(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 := strings.ToLower(ctx.FormString("team")) + if len(name) == 0 { + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + team, err := organization.OrgFromUser(ctx.Repo.Owner).GetTeam(ctx, name) + if err != nil { + if organization.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 organization.HasTeamRepo(ctx, 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 = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil { + ctx.ServerError("TeamAddRepository", 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 := organization.GetTeamByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetTeamByID", err) + return + } + + if err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("team.RemoveRepositorys", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.remove_team_success")) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration") +} diff --git a/routers/web/repo/setting/default_branch.go b/routers/web/repo/setting/default_branch.go new file mode 100644 index 0000000..881d148 --- /dev/null +++ b/routers/web/repo/setting/default_branch.go @@ -0,0 +1,54 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// SetDefaultBranchPost set default branch +func SetDefaultBranchPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.branches.update_default_branch") + ctx.Data["PageIsSettingsBranches"] = true + + repo.PrepareBranchList(ctx) + if ctx.Written() { + return + } + + repo := ctx.Repo.Repository + + switch ctx.FormString("action") { + case "default_branch": + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplBranches) + return + } + + branch := ctx.FormString("branch") + if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, branch); err != nil { + switch { + case git_model.IsErrBranchNotExist(err): + ctx.Status(http.StatusNotFound) + default: + 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.EscapedPath()) + default: + ctx.NotFound("", nil) + } +} diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go new file mode 100644 index 0000000..abc3eb4 --- /dev/null +++ b/routers/web/repo/setting/deploy_key.go @@ -0,0 +1,109 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// 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.Tr("secrets.secrets") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + + keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + 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 + ctx.Data["DisableSSH"] = setting.SSH.Disabled + + keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("ListDeployKeys", err) + return + } + ctx.Data["Deploykeys"] = keys + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplDeployKeys) + return + } + + content, err := asymkey_model.CheckPublicKeyString(form.Content) + if err != nil { + if db.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if asymkey_model.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else if err == asymkey_model.ErrKeyIsPrivate { + ctx.Data["HasError"] = true + ctx.Data["Err_Content"] = true + ctx.Flash.Error(ctx.Tr("form.must_use_public_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 := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable) + if err != nil { + ctx.Data["HasError"] = true + switch { + case asymkey_model.IsErrDeployKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_been_used"), tplDeployKeys, &form) + case asymkey_model.IsErrKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplDeployKeys, &form) + case asymkey_model.IsErrKeyNameAlreadyUsed(err): + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), tplDeployKeys, &form) + case asymkey_model.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 := asymkey_service.DeleteDeployKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteDeployKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") +} diff --git a/routers/web/repo/setting/git_hooks.go b/routers/web/repo/setting/git_hooks.go new file mode 100644 index 0000000..217a01c --- /dev/null +++ b/routers/web/repo/setting/git_hooks.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" +) + +// 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.FormString("content") + if err = hook.Update(); err != nil { + ctx.ServerError("hook.Update", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/hooks/git") +} diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go new file mode 100644 index 0000000..7e36343 --- /dev/null +++ b/routers/web/repo/setting/lfs.go @@ -0,0 +1,562 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "bytes" + "fmt" + gotemplate "html/template" + "io" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/container" + "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" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +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.FormInt("page") + if page <= 1 { + page = 1 + } + total, err := git_model.CountLFSMetaObjects(ctx, ctx.Repo.Repository.ID) + 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 := git_model.GetLFSMetaObjects(ctx, ctx.Repo.Repository.ID, 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.FormInt("page") + if page <= 1 { + page = 1 + } + total, err := git_model.CountLFSLockByRepoID(ctx, 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 := git_model.GetLFSLockByRepoID(ctx, ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum) + if err != nil { + ctx.ServerError("LFSLocks", err) + return + } + if err := lfsLocks.LoadAttributes(ctx); 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 := repo_module.CreateTemporaryPath("locks") + if err != nil { + log.Error("Failed to create temporary path: %v", err) + ctx.ServerError("LFSLocks", err) + return + } + defer func() { + if err := repo_module.RemoveTemporaryPath(tmpBasePath); err != nil { + log.Error("LFSLocks: RemoveTemporaryPath: %v", err) + } + }() + + if err := git.Clone(ctx, 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 (%w)", ctx.Repo.Repository.FullName(), err)) + return + } + + gitRepo, err := git.OpenRepository(ctx, 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 %w", 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 (%w)", ctx.Repo.Repository.DefaultBranch, err)) + return + } + + ctx.Data["Lockables"], err = lockablesGitAttributes(gitRepo, lfsLocks) + if err != nil { + log.Error("Unable to get lockablesGitAttributes in %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", err) + return + } + + filelist, err := gitRepo.LsFiles(filenames...) + if err != nil { + log.Error("Unable to lsfiles in %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", err) + return + } + + fileset := make(container.Set[string], len(filelist)) + fileset.AddMultiple(filelist...) + + linkable := make([]bool, len(lfsLocks)) + for i, lock := range lfsLocks { + linkable[i] = fileset.Contains(lock.Path) + } + ctx.Data["Linkable"] = linkable + + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsLFSLocks) +} + +func lockablesGitAttributes(gitRepo *git.Repository, lfsLocks []*git_model.LFSLock) ([]bool, error) { + checker, err := gitRepo.GitAttributeChecker("", "lockable") + if err != nil { + return nil, fmt.Errorf("could not GitAttributeChecker: %w", err) + } + defer checker.Close() + + lockables := make([]bool, len(lfsLocks)) + for i, lock := range lfsLocks { + attrs, err := checker.CheckPath(lock.Path) + if err != nil { + return nil, fmt.Errorf("could not CheckPath(%s): %w", lock.Path, err) + } + lockables[i] = attrs["lockable"].Bool().Value() + } + return lockables, nil +} + +// LFSLockFile locks a file +func LFSLockFile(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSLocks", nil) + return + } + originalPath := ctx.FormString("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 = util.PathJoinRel(lockPath) + 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 := git_model.CreateLFSLock(ctx, ctx.Repo.Repository, &git_model.LFSLock{ + Path: lockPath, + OwnerID: ctx.Doer.ID, + }) + if err != nil { + if git_model.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 := git_model.DeleteLFSLockByID(ctx, ctx.ParamsInt64("lid"), ctx.Repo.Repository, ctx.Doer, 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") + + p := lfs.Pointer{Oid: oid} + if !p.IsValid() { + ctx.NotFound("LFSFileGet", nil) + return + } + + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, oid) + if err != nil { + if err == git_model.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 := util.ReadAtMost(dataRc, 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/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct") + switch { + case isRepresentableAsText: + if st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + } + + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + // Building code view blocks with line number on server side. + escapedContent := &bytes.Buffer{} + ctx.Data["EscapeStatus"], _ = charset.EscapeControlReader(rd, escapedContent, ctx.Locale, charset.FileviewContext) + + var output bytes.Buffer + lines := strings.Split(escapedContent.String(), "\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") + p := lfs.Pointer{Oid: oid} + if !p.IsValid() { + ctx.NotFound("LFSDelete", nil) + return + } + + count, err := git_model.RemoveLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, 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.FormString("oid") + size := ctx.FormInt64("size") + if len(oid) == 0 || size == 0 { + ctx.NotFound("LFSFind", nil) + return + } + sha := ctx.FormString("sha") + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + objectFormat := ctx.Repo.GetObjectFormat() + var objectID git.ObjectID + if len(sha) == 0 { + pointer := lfs.Pointer{Oid: oid, Size: size} + objectID = git.ComputeBlobHash(objectFormat, []byte(pointer.StringContent())) + sha = objectID.String() + } else { + objectID = 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, objectID) + 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 + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + + var err error + 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 + Associatable 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 := git_model.GetLFSMetaObjectByOid(ctx, repo.ID, pointerBlob.Oid); err != nil { + if err != git_model.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.Associatable, err = git_model.LFSObjectAccessible(ctx, ctx.Doer, pointerBlob.Oid) + if err != nil { + return err + } + if !result.Associatable { + associated, err := git_model.ExistsLFSObject(ctx, pointerBlob.Oid) + if err != nil { + return err + } + result.Associatable = !associated + } + } + } + + result.Accessible = result.InRepo || result.Associatable + + if result.InRepo { + numAssociated++ + } + if !result.Exists { + numNoExist++ + } + if result.Associatable { + 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.FormStrings("oid") + metas := make([]*git_model.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] = &git_model.LFSMetaObject{} + metas[i].Size, err = strconv.ParseInt(oid[idx+1:], 10, 64) + if err != nil { + ctx.ServerError("LFSAutoAssociate", fmt.Errorf("illegal oid input: %s %w", oid, err)) + return + } + metas[i].Oid = oid[:idx] + // metas[i].RepositoryID = ctx.Repo.Repository.ID + } + if err := git_model.LFSAutoAssociate(ctx, metas, ctx.Doer, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("LFSAutoAssociate", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs") +} diff --git a/routers/web/repo/setting/main_test.go b/routers/web/repo/setting/main_test.go new file mode 100644 index 0000000..c414b85 --- /dev/null +++ b/routers/web/repo/setting/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go new file mode 100644 index 0000000..b2f5798 --- /dev/null +++ b/routers/web/repo/setting/protected_branch.go @@ -0,0 +1,347 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + pull_service "code.gitea.io/gitea/services/pull" + "code.gitea.io/gitea/services/repository" + + "github.com/gobwas/glob" +) + +const ( + tplProtectedBranch base.TplName = "repo/settings/protected_branch" +) + +// ProtectedBranchRules render the page to protect the repository +func ProtectedBranchRules(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.branches") + ctx.Data["PageIsSettingsBranches"] = true + + rules, err := git_model.FindRepoProtectedBranchRules(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetProtectedBranches", err) + return + } + ctx.Data["ProtectedBranches"] = rules + + repo.PrepareBranchList(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplBranches) +} + +// SettingsProtectedBranch renders the protected branch setting page +func SettingsProtectedBranch(c *context.Context) { + ruleName := c.FormString("rule_name") + var rule *git_model.ProtectedBranch + if ruleName != "" { + var err error + rule, err = git_model.GetProtectedBranchRuleByName(c, c.Repo.Repository.ID, ruleName) + if err != nil { + c.ServerError("GetProtectBranchOfRepoByName", err) + return + } + } + + if rule == nil { + // No options found, create defaults. + rule = &git_model.ProtectedBranch{} + } + + c.Data["PageIsSettingsBranches"] = true + c.Data["Title"] = c.Locale.TrString("repo.settings.protected_branch") + " - " + rule.RuleName + + users, err := access_model.GetRepoReaders(c, c.Repo.Repository) + if err != nil { + c.ServerError("Repo.Repository.GetReaders", err) + return + } + c.Data["Users"] = users + c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",") + c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",") + c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",") + c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n") + contexts, _ := git_model.FindRepoRecentCommitStatusContexts(c, c.Repo.Repository.ID, 7*24*time.Hour) // Find last week status check contexts + c.Data["recent_status_checks"] = contexts + + if c.Repo.Owner.IsOrganization() { + teams, err := organization.OrgFromUser(c.Repo.Owner).TeamsWithAccessToRepo(c, c.Repo.Repository.ID, perm.AccessModeRead) + if err != nil { + c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) + return + } + c.Data["Teams"] = teams + c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",") + c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",") + c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",") + } + + c.Data["Rule"] = rule + c.HTML(http.StatusOK, tplProtectedBranch) +} + +// SettingsProtectedBranchPost updates the protected branch settings +func SettingsProtectedBranchPost(ctx *context.Context) { + f := web.GetForm(ctx).(*forms.ProtectBranchForm) + var protectBranch *git_model.ProtectedBranch + if f.RuleName == "" { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_rule_name")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit", ctx.Repo.RepoLink)) + return + } + + var err error + if f.RuleID > 0 { + // If the RuleID isn't 0, it must be an edit operation. So we get rule by id. + protectBranch, err = git_model.GetProtectedBranchRuleByID(ctx, ctx.Repo.Repository.ID, f.RuleID) + if err != nil { + ctx.ServerError("GetProtectBranchOfRepoByID", err) + return + } + if protectBranch != nil && protectBranch.RuleName != f.RuleName { + // RuleName changed. We need to check if there is a rule with the same name. + // If a rule with the same name exists, an error should be returned. + sameNameProtectBranch, err := git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, f.RuleName) + if err != nil { + ctx.ServerError("GetProtectBranchOfRepoByName", err) + return + } + if sameNameProtectBranch != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) + return + } + } + } else { + // Check if a rule already exists with this rulename, if so redirect to it. + protectBranch, err = git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, f.RuleName) + if err != nil { + ctx.ServerError("GetProtectedBranchRuleByName", err) + return + } + if protectBranch != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) + return + } + } + if protectBranch == nil { + // No options found, create defaults. + protectBranch = &git_model.ProtectedBranch{ + RepoID: ctx.Repo.Repository.ID, + RuleName: f.RuleName, + } + } + + var whitelistUsers, whitelistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 + protectBranch.RuleName = f.RuleName + if f.RequiredApprovals < 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, f.RuleName)) + return + } + + 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 { + patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n") + validPatterns := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + continue + } + if _, err := glob.Compile(trimmed); err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.protect_invalid_status_check_pattern", pattern)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName))) + return + } + validPatterns = append(validPatterns, trimmed) + } + if len(validPatterns) == 0 { + // if status check is enabled, patterns slice is not allowed to be empty + ctx.Flash.Error(ctx.Tr("repo.settings.protect_no_valid_status_check_patterns")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName))) + return + } + protectBranch.StatusCheckContexts = validPatterns + } 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.IgnoreStaleApprovals = f.IgnoreStaleApprovals + protectBranch.RequireSignedCommits = f.RequireSignedCommits + protectBranch.ProtectedFilePatterns = f.ProtectedFilePatterns + protectBranch.UnprotectedFilePatterns = f.UnprotectedFilePatterns + protectBranch.BlockOnOutdatedBranch = f.BlockOnOutdatedBranch + protectBranch.ApplyToAdmins = f.ApplyToAdmins + + err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ + UserIDs: whitelistUsers, + TeamIDs: whitelistTeams, + MergeUserIDs: mergeWhitelistUsers, + MergeTeamIDs: mergeWhitelistTeams, + ApprovalsUserIDs: approvalsWhitelistUsers, + ApprovalsTeamIDs: approvalsWhitelistTeams, + }) + if err != nil { + ctx.ServerError("UpdateProtectBranch", err) + return + } + + // FIXME: since we only need to recheck files protected rules, we could improve this + matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, protectBranch.RuleName) + if err != nil { + ctx.ServerError("FindAllMatchedBranches", err) + return + } + for _, branchName := range matchedBranches { + if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil { + ctx.ServerError("CheckPRsForBaseBranch", err) + return + } + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) +} + +// DeleteProtectedBranchRulePost delete protected branch rule by id +func DeleteProtectedBranchRulePost(ctx *context.Context) { + ruleID := ctx.ParamsInt64("id") + if ruleID <= 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + rule, err := git_model.GetProtectedBranchRuleByID(ctx, ctx.Repo.Repository.ID, ruleID) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + if rule == nil { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, ruleID); err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", rule.RuleName)) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName)) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) +} + +// RenameBranchPost responses for rename a branch +func RenameBranchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RenameBranchForm) + + if !ctx.Repo.CanCreateBranch() { + ctx.NotFound("RenameBranch", nil) + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + return + } + + msg, err := repository.RenameBranch(ctx, ctx.Repo.Repository, ctx.Doer, ctx.Repo.GitRepo, form.From, form.To) + if err != nil { + if errors.Is(err, git_model.ErrBranchIsProtected) { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_protected", form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + } else if git_model.IsErrBranchAlreadyExists(err) { + ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + } else { + ctx.ServerError("RenameBranch", err) + } + return + } + + if msg == "target_exist" { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_exist", form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + return + } + + if msg == "from_not_exist" { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_not_exist", form.From)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.rename_branch_success", form.From, form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) +} diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go new file mode 100644 index 0000000..2c25b65 --- /dev/null +++ b/routers/web/repo/setting/protected_tag.go @@ -0,0 +1,188 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + "strings" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplTags base.TplName = "repo/settings/tags" +) + +// Tags render the page to protect tags +func ProtectedTags(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + ctx.HTML(http.StatusOK, tplTags) +} + +// NewProtectedTagPost handles creation of a protect tag +func NewProtectedTagPost(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTags) + return + } + + repo := ctx.Repo.Repository + form := web.GetForm(ctx).(*forms.ProtectTagForm) + + pt := &git_model.ProtectedTag{ + RepoID: repo.ID, + NamePattern: strings.TrimSpace(form.NamePattern), + } + + if strings.TrimSpace(form.AllowlistUsers) != "" { + pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ",")) + } + if strings.TrimSpace(form.AllowlistTeams) != "" { + pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ",")) + } + + if err := git_model.InsertProtectedTag(ctx, pt); err != nil { + ctx.ServerError("InsertProtectedTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) +} + +// EditProtectedTag render the page to edit a protect tag +func EditProtectedTag(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + ctx.Data["PageIsEditProtectedTag"] = true + + pt := selectProtectedTagByContext(ctx) + if pt == nil { + return + } + + ctx.Data["name_pattern"] = pt.NamePattern + ctx.Data["allowlist_users"] = strings.Join(base.Int64sToStrings(pt.AllowlistUserIDs), ",") + ctx.Data["allowlist_teams"] = strings.Join(base.Int64sToStrings(pt.AllowlistTeamIDs), ",") + + ctx.HTML(http.StatusOK, tplTags) +} + +// EditProtectedTagPost handles creation of a protect tag +func EditProtectedTagPost(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + ctx.Data["PageIsEditProtectedTag"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTags) + return + } + + pt := selectProtectedTagByContext(ctx) + if pt == nil { + return + } + + form := web.GetForm(ctx).(*forms.ProtectTagForm) + + pt.NamePattern = strings.TrimSpace(form.NamePattern) + pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ",")) + pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ",")) + + if err := git_model.UpdateProtectedTag(ctx, pt); err != nil { + ctx.ServerError("UpdateProtectedTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") +} + +// DeleteProtectedTagPost handles deletion of a protected tag +func DeleteProtectedTagPost(ctx *context.Context) { + pt := selectProtectedTagByContext(ctx) + if pt == nil { + return + } + + if err := git_model.DeleteProtectedTag(ctx, pt); err != nil { + ctx.ServerError("DeleteProtectedTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") +} + +func setTagsContext(ctx *context.Context) error { + ctx.Data["Title"] = ctx.Tr("repo.settings.tags") + ctx.Data["PageIsSettingsTags"] = true + + protectedTags, err := git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetProtectedTags", err) + return err + } + ctx.Data["ProtectedTags"] = protectedTags + + users, err := access_model.GetRepoReaders(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("Repo.Repository.GetReaders", err) + return err + } + ctx.Data["Users"] = users + + if ctx.Repo.Owner.IsOrganization() { + teams, err := organization.OrgFromUser(ctx.Repo.Owner).TeamsWithAccessToRepo(ctx, ctx.Repo.Repository.ID, perm.AccessModeRead) + if err != nil { + ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) + return err + } + ctx.Data["Teams"] = teams + } + + return nil +} + +func selectProtectedTagByContext(ctx *context.Context) *git_model.ProtectedTag { + id := ctx.FormInt64("id") + if id == 0 { + id = ctx.ParamsInt64(":id") + } + + tag, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.ServerError("GetProtectedTagByID", err) + return nil + } + + if tag != nil && tag.RepoID == ctx.Repo.Repository.ID { + return tag + } + + ctx.NotFound("", fmt.Errorf("ProtectedTag[%v] not associated to repository %v", id, ctx.Repo.Repository)) + + return nil +} diff --git a/routers/web/repo/setting/runners.go b/routers/web/repo/setting/runners.go new file mode 100644 index 0000000..a47d3b4 --- /dev/null +++ b/routers/web/repo/setting/runners.go @@ -0,0 +1,187 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + "net/url" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + actions_shared "code.gitea.io/gitea/routers/web/shared/actions" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + // TODO: Separate secrets from runners when layout is ready + tplRepoRunners base.TplName = "repo/settings/actions" + tplOrgRunners base.TplName = "org/settings/actions" + tplAdminRunners base.TplName = "admin/actions" + tplUserRunners base.TplName = "user/settings/actions" + tplRepoRunnerEdit base.TplName = "repo/settings/runner_edit" + tplOrgRunnerEdit base.TplName = "org/settings/runners_edit" + tplAdminRunnerEdit base.TplName = "admin/runners/edit" + tplUserRunnerEdit base.TplName = "user/settings/runner_edit" +) + +type runnersCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsAdmin bool + IsUser bool + RunnersTemplate base.TplName + RunnerEditTemplate base.TplName + RedirectLink string +} + +func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &runnersCtx{ + RepoID: ctx.Repo.Repository.ID, + OwnerID: 0, + IsRepo: true, + RunnersTemplate: tplRepoRunners, + RunnerEditTemplate: tplRepoRunnerEdit, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } + return &runnersCtx{ + RepoID: 0, + OwnerID: ctx.Org.Organization.ID, + IsOrg: true, + RunnersTemplate: tplOrgRunners, + RunnerEditTemplate: tplOrgRunnerEdit, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/", + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &runnersCtx{ + RepoID: 0, + OwnerID: 0, + IsAdmin: true, + RunnersTemplate: tplAdminRunners, + RunnerEditTemplate: tplAdminRunnerEdit, + RedirectLink: setting.AppSubURL + "/admin/actions/runners/", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &runnersCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + RunnersTemplate: tplUserRunners, + RunnerEditTemplate: tplUserRunnerEdit, + RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/", + }, nil + } + + return nil, errors.New("unable to set Runners context") +} + +// Runners render settings/actions/runners page for repo level +func Runners(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsRunners"] = true + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageType"] = "runners" + + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + opts := actions_model.FindRunnerOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: 100, + }, + Sort: ctx.Req.URL.Query().Get("sort"), + Filter: ctx.Req.URL.Query().Get("q"), + } + if rCtx.IsRepo { + opts.RepoID = rCtx.RepoID + opts.WithAvailable = true + } else if rCtx.IsOrg || rCtx.IsUser { + opts.OwnerID = rCtx.OwnerID + opts.WithAvailable = true + } + actions_shared.RunnersList(ctx, opts) + + ctx.HTML(http.StatusOK, rCtx.RunnersTemplate) +} + +// RunnersEdit renders runner edit page for repository level +func RunnersEdit(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsRunners"] = true + ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner") + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + actions_shared.RunnerDetails(ctx, page, + ctx.ParamsInt64(":runnerid"), rCtx.OwnerID, rCtx.RepoID, + ) + ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate) +} + +func RunnersEditPost(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + actions_shared.RunnerDetailsEditPost(ctx, ctx.ParamsInt64(":runnerid"), + rCtx.OwnerID, rCtx.RepoID, + rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid"))) +} + +func ResetRunnerRegistrationToken(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + actions_shared.RunnerResetRegistrationToken(ctx, rCtx.OwnerID, rCtx.RepoID, rCtx.RedirectLink) +} + +// RunnerDeletePost response for deleting runner +func RunnerDeletePost(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + actions_shared.RunnerDeletePost(ctx, ctx.ParamsInt64(":runnerid"), rCtx.RedirectLink, rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid"))) +} + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners") +} diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go new file mode 100644 index 0000000..d4d56bf --- /dev/null +++ b/routers/web/repo/setting/secrets.go @@ -0,0 +1,127 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared "code.gitea.io/gitea/routers/web/shared/secrets" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + // TODO: Separate secrets from runners when layout is ready + tplRepoSecrets base.TplName = "repo/settings/actions" + tplOrgSecrets base.TplName = "org/settings/actions" + tplUserSecrets base.TplName = "user/settings/actions" +) + +type secretsCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsUser bool + SecretsTemplate base.TplName + RedirectLink string +} + +func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &secretsCtx{ + OwnerID: 0, + RepoID: ctx.Repo.Repository.ID, + IsRepo: true, + SecretsTemplate: tplRepoSecrets, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/secrets", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } + return &secretsCtx{ + OwnerID: ctx.ContextUser.ID, + RepoID: 0, + IsOrg: true, + SecretsTemplate: tplOrgSecrets, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/secrets", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &secretsCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + SecretsTemplate: tplUserSecrets, + RedirectLink: setting.AppSubURL + "/user/settings/actions/secrets", + }, nil + } + + return nil, errors.New("unable to set Secrets context") +} + +func Secrets(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageType"] = "secrets" + ctx.Data["PageIsSharedSettingsSecrets"] = true + + sCtx, err := getSecretsCtx(ctx) + if err != nil { + ctx.ServerError("getSecretsCtx", err) + return + } + + if sCtx.IsRepo { + ctx.Data["DisableSSH"] = setting.SSH.Disabled + } + + shared.SetSecretsContext(ctx, sCtx.OwnerID, sCtx.RepoID) + if ctx.Written() { + return + } + ctx.HTML(http.StatusOK, sCtx.SecretsTemplate) +} + +func SecretsPost(ctx *context.Context) { + sCtx, err := getSecretsCtx(ctx) + if err != nil { + ctx.ServerError("getSecretsCtx", err) + return + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.PerformSecretsPost( + ctx, + sCtx.OwnerID, + sCtx.RepoID, + sCtx.RedirectLink, + ) +} + +func SecretsDelete(ctx *context.Context) { + sCtx, err := getSecretsCtx(ctx) + if err != nil { + ctx.ServerError("getSecretsCtx", err) + return + } + shared.PerformSecretsDelete( + ctx, + sCtx.OwnerID, + sCtx.RepoID, + sCtx.RedirectLink, + ) +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go new file mode 100644 index 0000000..ce506ea --- /dev/null +++ b/routers/web/repo/setting/setting.go @@ -0,0 +1,1113 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/indexer/stats" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/federation" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/migrations" + mirror_service "code.gitea.io/gitea/services/mirror" + repo_service "code.gitea.io/gitea/services/repository" + wiki_service "code.gitea.io/gitea/services/wiki" +) + +const ( + tplSettingsOptions base.TplName = "repo/settings/options" + tplSettingsUnits base.TplName = "repo/settings/units" + 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" +) + +// SettingsCtxData is a middleware that sets all the general context data for the +// settings template. +func SettingsCtxData(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.options") + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate + ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush + ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval + ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval + + signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Data["SigningKeyAvailable"] = len(signing) > 0 + ctx.Data["SigningSettings"] = setting.Repository.Signing + ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + if ctx.Doer.IsAdmin { + if setting.Indexer.RepoIndexerEnabled { + status, err := repo_model.GetIndexerStatus(ctx, ctx.Repo.Repository, repo_model.RepoIndexerTypeCode) + if err != nil { + ctx.ServerError("repo.indexer_status", err) + return + } + ctx.Data["CodeIndexerStatus"] = status + } + status, err := repo_model.GetIndexerStatus(ctx, ctx.Repo.Repository, repo_model.RepoIndexerTypeStats) + if err != nil { + ctx.ServerError("repo.indexer_status", err) + return + } + ctx.Data["StatsIndexerStatus"] = status + } + pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, ctx.Repo.Repository.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("GetPushMirrorsByRepoID", err) + return + } + ctx.Data["PushMirrors"] = pushMirrors + ctx.Data["CanUseSSHMirroring"] = git.HasSSHExecutable +} + +// Units show a repositorys unit settings page +func Units(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.units.units") + ctx.Data["PageIsRepoSettingsUnits"] = true + + ctx.HTML(http.StatusOK, tplSettingsUnits) +} + +func UnitsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoUnitSettingForm) + + repo := ctx.Repo.Repository + + var repoChanged bool + var units []repo_model.RepoUnit + var deleteUnitTypes []unit_model.Type + + // 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.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeCode, + }) + } else if !unit_model.TypeCode.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode) + } + + if form.EnableWiki && form.EnableExternalWiki && !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalWikiURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_wiki_url_error")) + ctx.Redirect(repo.Link() + "/settings/units") + return + } + + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeExternalWiki, + Config: &repo_model.ExternalWikiConfig{ + ExternalWikiURL: form.ExternalWikiURL, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() { + var wikiPermissions repo_model.UnitAccessMode + if form.GloballyWriteableWiki { + wikiPermissions = repo_model.UnitAccessModeWrite + } else { + wikiPermissions = repo_model.UnitAccessModeRead + } + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeWiki, + Config: new(repo_model.UnitConfig), + DefaultPermissions: wikiPermissions, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) + } else { + if !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) + } + if !unit_model.TypeWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } + } + + if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalTrackerURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error")) + ctx.Redirect(repo.Link() + "/settings/units") + 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/units") + return + } + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeExternalTracker, + Config: &repo_model.ExternalTrackerConfig{ + ExternalTrackerURL: form.ExternalTrackerURL, + ExternalTrackerFormat: form.TrackerURLFormat, + ExternalTrackerStyle: form.TrackerIssueStyle, + ExternalTrackerRegexpPattern: form.ExternalTrackerRegexpPattern, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeIssues, + Config: &repo_model.IssuesConfig{ + EnableTimetracker: form.EnableTimetracker, + AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, + EnableDependencies: form.EnableIssueDependencies, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) + } else { + if !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) + } + if !unit_model.TypeIssues.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } + } + + if form.EnableProjects && !unit_model.TypeProjects.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeProjects, + }) + } else if !unit_model.TypeProjects.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects) + } + + if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeReleases, + }) + } else if !unit_model.TypeReleases.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases) + } + + if form.EnablePackages && !unit_model.TypePackages.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypePackages, + }) + } else if !unit_model.TypePackages.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages) + } + + if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeActions, + }) + } else if !unit_model.TypeActions.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions) + } + + if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypePullRequests, + Config: &repo_model.PullRequestsConfig{ + IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace, + AllowMerge: form.PullsAllowMerge, + AllowRebase: form.PullsAllowRebase, + AllowRebaseMerge: form.PullsAllowRebaseMerge, + AllowSquash: form.PullsAllowSquash, + AllowFastForwardOnly: form.PullsAllowFastForwardOnly, + AllowManualMerge: form.PullsAllowManualMerge, + AutodetectManualMerge: form.EnableAutodetectManualMerge, + AllowRebaseUpdate: form.PullsAllowRebaseUpdate, + DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge, + DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle), + DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit, + }, + }) + } else if !unit_model.TypePullRequests.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests) + } + + if len(units) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.update_settings_no_unit")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/units") + return + } + + if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { + ctx.ServerError("UpdateRepositoryUnits", err) + return + } + if repoChanged { + if err := repo_service.UpdateRepository(ctx, 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/units") +} + +// Settings show a repository's settings page +func Settings(ctx *context.Context) { + 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["ForcePrivate"] = setting.Repository.ForcePrivate + ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush + ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval + ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval + + signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Data["SigningKeyAvailable"] = len(signing) > 0 + ctx.Data["SigningSettings"] = setting.Repository.Signing + ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + repo := ctx.Repo.Repository + + switch ctx.FormString("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, ctx.Doer, repo, newRepoName); err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case repo_model.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form) + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) + case repo_model.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 db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.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.Doer.IsAdmin { + ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form) + return + } + + repo.IsPrivate = form.Private + if err := repo_service.UpdateRepository(ctx, 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 "federation": + if !setting.Federation.Enabled { + ctx.NotFound("", nil) + ctx.Flash.Info(ctx.Tr("repo.settings.federation_not_enabled")) + return + } + followingRepos := strings.TrimSpace(form.FollowingRepos) + followingRepos = strings.TrimSuffix(followingRepos, ";") + + maxFollowingRepoStrLength := 2048 + errs := validation.ValidateMaxLen(followingRepos, maxFollowingRepoStrLength, "federationRepos") + if len(errs) > 0 { + ctx.Data["ERR_FollowingRepos"] = true + ctx.Flash.Error(ctx.Tr("repo.form.string_too_long", maxFollowingRepoStrLength)) + ctx.Redirect(repo.Link() + "/settings") + return + } + + federationRepoSplit := []string{} + if followingRepos != "" { + federationRepoSplit = strings.Split(followingRepos, ";") + } + for idx, repo := range federationRepoSplit { + federationRepoSplit[idx] = strings.TrimSpace(repo) + } + + if _, _, err := federation.StoreFollowingRepoList(ctx, ctx.Repo.Repository.ID, federationRepoSplit); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "mirror": + if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { + ctx.NotFound("", nil) + return + } + + pullMirror, err := repo_model.GetMirrorByRepoID(ctx, ctx.Repo.Repository.ID) + if err == repo_model.ErrMirrorNotExist { + ctx.NotFound("", nil) + return + } + if err != nil { + ctx.ServerError("GetMirrorByRepoID", err) + 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) + return + } + + pullMirror.EnablePrune = form.EnablePrune + pullMirror.Interval = interval + pullMirror.ScheduleNextUpdate() + if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { + ctx.ServerError("UpdateMirror", err) + return + } + + u, err := git.GetRemoteURL(ctx, ctx.Repo.Repository.RepoPath(), pullMirror.GetRemoteName()) + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + if u.User != nil && form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() { + form.MirrorPassword, _ = u.User.Password() + } + + address, err := forms.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.Doer) + } + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + + if err := mirror_service.UpdateAddress(ctx, pullMirror, address); err != nil { + ctx.ServerError("UpdateAddress", err) + return + } + remoteAddress, err := util.SanitizeURL(address) + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + pullMirror.RemoteAddress = remoteAddress + + 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.Doer) + if err != nil { + ctx.Data["Err_LFSEndpoint"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + } + + pullMirror.LFS = form.LFS + pullMirror.LFSEndpoint = form.LFSEndpoint + if err := repo_model.UpdateMirror(ctx, pullMirror); 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 !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { + ctx.NotFound("", nil) + return + } + + ok, err := quota_model.EvaluateForUser(ctx, repo.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + // 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 + + ctx.RenderWithErr(ctx.Tr("repo.settings.pull_mirror_sync_quota_exceeded"), tplSettingsOptions, &form) + return + } + + mirror_service.AddPullMirrorToQueue(repo.ID) + + ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-sync": + if !setting.Mirror.Enabled { + ctx.NotFound("", nil) + return + } + + m, err := selectPushMirrorByForm(ctx, form, repo) + if err != nil { + ctx.NotFound("", nil) + return + } + + mirror_service.AddPushMirrorToQueue(m.ID) + + ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress)) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-update": + if !setting.Mirror.Enabled || repo.IsArchived { + 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 + + m, err := selectPushMirrorByForm(ctx, form, repo) + if err != nil { + ctx.NotFound("", nil) + return + } + + interval, err := time.ParseDuration(form.PushMirrorInterval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &forms.RepoSettingForm{}) + return + } + + m.Interval = interval + if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil { + ctx.ServerError("UpdatePushMirrorInterval", err) + return + } + // Background why we are adding it to Queue + // If we observed its implementation in the context of `push-mirror-sync` where it + // is evident that pushing to the queue is necessary for updates. + // So, there are updates within the given interval, it is necessary to update the queue accordingly. + mirror_service.AddPushMirrorToQueue(m.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-remove": + if !setting.Mirror.Enabled || repo.IsArchived { + 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 + + m, err := selectPushMirrorByForm(ctx, form, repo) + if err != nil { + ctx.NotFound("", nil) + return + } + + if err = mirror_service.RemovePushMirrorRemote(ctx, m); err != nil { + ctx.ServerError("RemovePushMirrorRemote", err) + return + } + + if err = repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { + ctx.ServerError("DeletePushMirrorByID", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-add": + if setting.Mirror.DisableNewPush || repo.IsArchived { + 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.PushMirrorInterval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.Data["Err_PushMirrorInterval"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) + return + } + + if form.PushMirrorUseSSH && (form.PushMirrorUsername != "" || form.PushMirrorPassword != "") { + ctx.Data["Err_PushMirrorUseSSH"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_denied_combination"), tplSettingsOptions, &form) + return + } + + if form.PushMirrorUseSSH && !git.HasSSHExecutable { + ctx.RenderWithErr(ctx.Tr("repo.mirror_use_ssh.not_available"), tplSettingsOptions, &form) + return + } + + address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.Doer) + } + if err != nil { + ctx.Data["Err_PushMirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + + remoteSuffix, err := util.CryptoRandomString(10) + if err != nil { + ctx.ServerError("RandomString", err) + return + } + + remoteAddress, err := util.SanitizeURL(address) + if err != nil { + ctx.Data["Err_PushMirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + + m := &repo_model.PushMirror{ + RepoID: repo.ID, + Repo: repo, + RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), + SyncOnCommit: form.PushMirrorSyncOnCommit, + Interval: interval, + RemoteAddress: remoteAddress, + } + + var plainPrivateKey []byte + if form.PushMirrorUseSSH { + publicKey, privateKey, err := util.GenerateSSHKeypair() + if err != nil { + ctx.ServerError("GenerateSSHKeypair", err) + return + } + plainPrivateKey = privateKey + m.PublicKey = string(publicKey) + } + + if err := db.Insert(ctx, m); err != nil { + ctx.ServerError("InsertPushMirror", err) + return + } + + if form.PushMirrorUseSSH { + if err := m.SetPrivatekey(ctx, plainPrivateKey); err != nil { + ctx.ServerError("SetPrivatekey", err) + return + } + } + + if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil { + if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { + log.Error("DeletePushMirrors %v", err) + } + ctx.ServerError("AddPushMirrorRemote", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "signing": + changed := false + trustModel := repo_model.ToTrustModel(form.TrustModel) + if trustModel != repo.TrustModel { + repo.TrustModel = trustModel + changed = true + } + + if changed { + if err := repo_service.UpdateRepository(ctx, 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.Doer.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + + if repo.IsFsckEnabled != form.EnableHealthCheck { + repo.IsFsckEnabled = form.EnableHealthCheck + } + + if err := repo_service.UpdateRepository(ctx, 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 "admin_index": + if !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + + switch form.RequestReindexType { + case "stats": + if err := stats.UpdateRepoIndexer(ctx.Repo.Repository); err != nil { + ctx.ServerError("UpdateStatsRepondexer", err) + return + } + case "code": + if !setting.Indexer.RepoIndexerEnabled { + ctx.Error(http.StatusForbidden) + return + } + code.UpdateRepoIndexer(ctx.Repo.Repository) + default: + ctx.NotFound("", nil) + return + } + + log.Trace("Repository reindex for %s requested: %s/%s", form.RequestReindexType, ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.reindex_requested")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "convert": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != 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 := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil { + ctx.ServerError("CleanUpMigrateInfo", err) + return + } else if err = repo_model.DeleteMirrorByRepoID(ctx, 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.LoadOwner(ctx); err != nil { + ctx.ServerError("Convert Fork", err) + return + } + if repo.FullName() != 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() { + maxCreationLimit := ctx.Repo.Owner.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.Flash.Error(msg) + ctx.Redirect(repo.Link() + "/settings") + return + } + + if err := repo_service.ConvertForkToNormalRepository(ctx, repo); err != nil { + log.Error("Unable to convert repository %-v from fork. Error: %v", repo, err) + 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.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + newOwner, err := user_model.GetUserByName(ctx, ctx.FormString("new_owner_name")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + return + } + ctx.ServerError("IsUserExist", err) + return + } + + if newOwner.Type == user_model.UserTypeOrganization { + if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { + // The user shouldn't know about this organization + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + return + } + } + + // Check the quota of the new owner + ok, err := quota_model.EvaluateForUser(ctx, newOwner.ID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_quota_exceeded", newOwner.Name), tplSettingsOptions, &form) + return + } + + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + + oldFullname := repo.FullName() + if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_blocked_doer"), tplSettingsOptions, nil) + } else if repo_model.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 + } + + if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer { + 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())) + } else { + log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) + } + ctx.Redirect(repo.Link() + "/settings") + + case "cancel_transfer": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) + if err != nil { + if models.IsErrNoPendingTransfer(err) { + ctx.Flash.Error("repo.settings.transfer_abort_invalid") + ctx.Redirect(repo.Link() + "/settings") + } else { + ctx.ServerError("GetPendingRepositoryTransfer", err) + } + + return + } + + if err := repoTransfer.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadRecipient", err) + return + } + + if err := repo_service.CancelRepositoryTransfer(ctx, 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(repo.Link() + "/settings") + + case "delete": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != 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, ctx.Doer, ctx.Repo.Repository, true); 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.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + err := wiki_service.DeleteWiki(ctx, repo) + 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 "rename-wiki-branch": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + if err := wiki_service.NormalizeWikiBranch(ctx, repo, setting.Repository.DefaultBranch); err != nil { + log.Error("Normalize Wiki branch: %v", err.Error()) + ctx.Flash.Error(ctx.Tr("repo.settings.wiki_branch_rename_failure")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + log.Trace("Repository wiki normalized: %s#%s", repo.FullName(), setting.Repository.DefaultBranch) + + ctx.Flash.Success(ctx.Tr("repo.settings.wiki_branch_rename_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_model.SetArchiveRepoState(ctx, repo, 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 + } + + if err := actions_model.CleanRepoScheduleTasks(ctx, repo, true); err != nil { + log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + + 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_model.SetArchiveRepoState(ctx, repo, 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 + } + + if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + } + + 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", addrErr.Host), tplSettingsOptions, form) + case addrErr.IsPermissionDenied: + if addrErr.LocalPath { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied"), tplSettingsOptions, form) + } else { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_blocked"), tplSettingsOptions, form) + } + case addrErr.IsInvalidPath: + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_local_path"), tplSettingsOptions, form) + default: + ctx.ServerError("Unknown error", err) + } + return + } + ctx.RenderWithErr(ctx.Tr("repo.mirror_address_url_invalid"), tplSettingsOptions, form) +} + +func selectPushMirrorByForm(ctx *context.Context, form *forms.RepoSettingForm, repo *repo_model.Repository) (*repo_model.PushMirror, error) { + id, err := strconv.ParseInt(form.PushMirrorID, 10, 64) + if err != nil { + return nil, err + } + + pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{}) + if err != nil { + return nil, err + } + + for _, m := range pushMirrors { + if m.ID == id { + m.Repo = repo + return m, nil + } + } + + return nil, fmt.Errorf("PushMirror[%v] not associated to repository %v", id, repo) +} diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go new file mode 100644 index 0000000..0c8553f --- /dev/null +++ b/routers/web/repo/setting/settings_test.go @@ -0,0 +1,412 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "testing" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createSSHAuthorizedKeysTmpPath(t *testing.T) func() { + tmpDir := t.TempDir() + + oldPath := setting.SSH.RootPath + setting.SSH.RootPath = tmpDir + + return func() { + setting.SSH.RootPath = oldPath + } +} + +func TestAddReadOnlyDeployKey(t *testing.T) { + if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { + defer deferable() + } else { + return + } + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys") + + contexttest.LoadUser(t, ctx, 2) + contexttest.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.StatusSeeOther, ctx.Resp.Status()) + + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ + Name: addKeyForm.Title, + Content: addKeyForm.Content, + Mode: perm.AccessModeRead, + }) +} + +func TestAddReadWriteOnlyDeployKey(t *testing.T) { + if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { + defer deferable() + } else { + return + } + + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys") + + contexttest.LoadUser(t, ctx, 2) + contexttest.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.StatusSeeOther, ctx.Resp.Status()) + + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ + Name: addKeyForm.Title, + Content: addKeyForm.Content, + Mode: perm.AccessModeWrite, + }) +} + +func TestCollaborationPost(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 4) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user4") + + u := &user_model.User{ + ID: 2, + LowerName: "user2", + Type: user_model.UserTypeIndividual, + } + + re := &repo_model.Repository{ + ID: 2, + Owner: u, + OwnerID: u.ID, + } + + repo := &context.Repository{ + Owner: u, + Repository: re, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + + exists, err := repo_model.IsCollaborator(ctx, re.ID, 4) + require.NoError(t, err) + assert.True(t, exists) +} + +func TestCollaborationPost_InactiveUser(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 9) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user9") + + repo := &context.Repository{ + Owner: &user_model.User{ + LowerName: "user2", + }, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 4) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user4") + + u := &user_model.User{ + ID: 2, + LowerName: "user2", + Type: user_model.UserTypeIndividual, + } + + re := &repo_model.Repository{ + ID: 2, + Owner: u, + OwnerID: u.ID, + } + + repo := &context.Repository{ + Owner: u, + Repository: re, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + + exists, err := repo_model.IsCollaborator(ctx, re.ID, 4) + require.NoError(t, err) + assert.True(t, exists) + + // Try adding the same collaborator again + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestCollaborationPost_NonExistentUser(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user34") + + repo := &context.Repository{ + Owner: &user_model.User{ + LowerName: "user2", + }, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 11, + OrgID: 26, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Empty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_NotAllowed(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 11, + OrgID: 26, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: false, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_AddTeamTwice(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 11, + OrgID: 26, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + AddTeamPost(ctx) + assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_NonExistentTeam(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team-non-existent") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestDeleteTeam(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org3/team1/repo3") + + ctx.Req.Form.Set("id", "2") + + org := &user_model.User{ + LowerName: "org3", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 2, + OrgID: 3, + } + + re := &repo_model.Repository{ + ID: 3, + Owner: org, + OwnerID: 3, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 3, + LowerName: "org3", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + DeleteTeam(ctx) + + assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) +} diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go new file mode 100644 index 0000000..45b6c0f --- /dev/null +++ b/routers/web/repo/setting/variables.go @@ -0,0 +1,140 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared "code.gitea.io/gitea/routers/web/shared/actions" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplRepoVariables base.TplName = "repo/settings/actions" + tplOrgVariables base.TplName = "org/settings/actions" + tplUserVariables base.TplName = "user/settings/actions" + tplAdminVariables base.TplName = "admin/actions" +) + +type variablesCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsUser bool + IsGlobal bool + VariablesTemplate base.TplName + RedirectLink string +} + +func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &variablesCtx{ + OwnerID: 0, + RepoID: ctx.Repo.Repository.ID, + IsRepo: true, + VariablesTemplate: tplRepoVariables, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } + return &variablesCtx{ + OwnerID: ctx.ContextUser.ID, + RepoID: 0, + IsOrg: true, + VariablesTemplate: tplOrgVariables, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &variablesCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + VariablesTemplate: tplUserVariables, + RedirectLink: setting.AppSubURL + "/user/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &variablesCtx{ + OwnerID: 0, + RepoID: 0, + IsGlobal: true, + VariablesTemplate: tplAdminVariables, + RedirectLink: setting.AppSubURL + "/admin/actions/variables", + }, nil + } + + return nil, errors.New("unable to set Variables context") +} + +func Variables(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.variables") + ctx.Data["PageType"] = "variables" + ctx.Data["PageIsSharedSettingsVariables"] = true + + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + shared.SetVariablesContext(ctx, vCtx.OwnerID, vCtx.RepoID) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, vCtx.VariablesTemplate) +} + +func VariableCreate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, vCtx.RedirectLink) +} + +func VariableUpdate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.UpdateVariable(ctx, vCtx.RedirectLink) +} + +func VariableDelete(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + shared.DeleteVariable(ctx, vCtx.RedirectLink) +} diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go new file mode 100644 index 0000000..eee493e --- /dev/null +++ b/routers/web/repo/setting/webhook.go @@ -0,0 +1,485 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "path" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web/middleware" + webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + webhook_service "code.gitea.io/gitea/services/webhook" + + "gitea.com/go-chi/binding" +) + +const ( + tplHooks base.TplName = "repo/settings/webhook/base" + tplHookNew base.TplName = "repo/settings/webhook/new" + tplOrgHookNew base.TplName = "org/settings/hook_new" + tplUserHookNew base.TplName = "user/settings/hook_new" + tplAdminHookNew base.TplName = "admin/hook_new" +) + +// WebhookList render web hooks list page +func WebhookList(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["WebhookList"] = webhook_service.List() + ctx.Data["Description"] = ctx.Tr("repo.settings.hooks_desc", "https://forgejo.org/docs/latest/user/webhooks/") + + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("GetWebhooksByRepoID", err) + return + } + ctx.Data["Webhooks"] = ws + + ctx.HTML(http.StatusOK, tplHooks) +} + +type ownerRepoCtx struct { + OwnerID int64 + RepoID int64 + IsAdmin bool + IsSystemWebhook bool + Link string + LinkNew string + NewTemplate base.TplName +} + +// getOwnerRepoCtx determines whether this is a repo, owner, or admin (both default and system) context. +func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &ownerRepoCtx{ + 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 ctx.Data["PageIsOrgSettings"] == true { + return &ownerRepoCtx{ + OwnerID: ctx.ContextUser.ID, + Link: path.Join(ctx.Org.OrgLink, "settings/hooks"), + LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"), + NewTemplate: tplOrgHookNew, + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &ownerRepoCtx{ + OwnerID: ctx.Doer.ID, + Link: path.Join(setting.AppSubURL, "/user/settings/hooks"), + LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"), + NewTemplate: tplUserHookNew, + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &ownerRepoCtx{ + IsAdmin: true, + IsSystemWebhook: ctx.Params(":configType") == "system-hooks", + Link: path.Join(setting.AppSubURL, "/admin/hooks"), + LinkNew: path.Join(setting.AppSubURL, "/admin/", ctx.Params(":configType")), + NewTemplate: tplAdminHookNew, + }, nil + } + + return nil, errors.New("unable to set OwnerRepo context") +} + +// WebhookNew render creating webhook page +func WebhookNew(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") + ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + + orCtx, err := getOwnerRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOwnerRepoCtx", 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 := ctx.Params(":type") + handler := webhook_service.GetWebhookHandler(hookType) + if handler == nil { + ctx.NotFound("GetWebhookHandler", nil) + return + } + ctx.Data["HookType"] = hookType + ctx.Data["WebhookHandler"] = handler + ctx.Data["BaseLink"] = orCtx.LinkNew + ctx.Data["BaseLinkNew"] = orCtx.LinkNew + ctx.Data["WebhookList"] = webhook_service.List() + + ctx.HTML(http.StatusOK, orCtx.NewTemplate) +} + +// ParseHookEvent convert web form content to webhook.HookEvent +func ParseHookEvent(form forms.WebhookCoreForm) *webhook_module.HookEvent { + return &webhook_module.HookEvent{ + PushOnly: form.PushOnly(), + SendEverything: form.SendEverything(), + ChooseEvents: form.ChooseEvents(), + HookEvents: webhook_module.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, + PullRequestReviewRequest: form.PullRequestReviewRequest, + Wiki: form.Wiki, + Repository: form.Repository, + Package: form.Package, + }, + BranchFilter: form.BranchFilter, + } +} + +func WebhookCreate(ctx *context.Context) { + hookType := ctx.Params(":type") + handler := webhook_service.GetWebhookHandler(hookType) + if handler == nil { + ctx.NotFound("GetWebhookHandler", nil) + return + } + + fields := handler.UnmarshalForm(func(form any) { + errs := binding.Bind(ctx.Req, form) + middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError + }) + + ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + ctx.Data["HookType"] = hookType + ctx.Data["WebhookHandler"] = handler + + orCtx, err := getOwnerRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOwnerRepoCtx", err) + return + } + ctx.Data["BaseLink"] = orCtx.LinkNew + ctx.Data["BaseLinkNew"] = orCtx.LinkNew + ctx.Data["WebhookList"] = webhook_service.List() + + if ctx.HasError() { + // pre-fill the form with the submitted data + var w webhook.Webhook + w.URL = fields.URL + w.ContentType = fields.ContentType + w.Secret = fields.Secret + w.HookEvent = ParseHookEvent(fields.WebhookCoreForm) + w.IsActive = fields.Active + w.HTTPMethod = fields.HTTPMethod + err := w.SetHeaderAuthorization(fields.AuthorizationHeader) + if err != nil { + ctx.ServerError("SetHeaderAuthorization", err) + return + } + ctx.Data["Webhook"] = w + ctx.Data["HookMetadata"] = fields.Metadata + + ctx.HTML(http.StatusUnprocessableEntity, orCtx.NewTemplate) + return + } + + var meta []byte + if fields.Metadata != nil { + meta, err = json.Marshal(fields.Metadata) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + } + + w := &webhook.Webhook{ + RepoID: orCtx.RepoID, + URL: fields.URL, + HTTPMethod: fields.HTTPMethod, + ContentType: fields.ContentType, + Secret: fields.Secret, + HookEvent: ParseHookEvent(fields.WebhookCoreForm), + IsActive: fields.Active, + Type: hookType, + Meta: string(meta), + OwnerID: orCtx.OwnerID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + err = w.SetHeaderAuthorization(fields.AuthorizationHeader) + if err != nil { + ctx.ServerError("SetHeaderAuthorization", err) + return + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := webhook.CreateWebhook(ctx, w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +func WebhookUpdate(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 + + handler := webhook_service.GetWebhookHandler(w.Type) + if handler == nil { + ctx.NotFound("GetWebhookHandler", nil) + return + } + + fields := handler.UnmarshalForm(func(form any) { + errs := binding.Bind(ctx.Req, form) + middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError + }) + + // pre-fill the form with the submitted data + w.URL = fields.URL + w.ContentType = fields.ContentType + w.Secret = fields.Secret + w.HookEvent = ParseHookEvent(fields.WebhookCoreForm) + w.IsActive = fields.Active + w.HTTPMethod = fields.HTTPMethod + + err := w.SetHeaderAuthorization(fields.AuthorizationHeader) + if err != nil { + ctx.ServerError("SetHeaderAuthorization", err) + return + } + + if ctx.HasError() { + ctx.Data["HookMetadata"] = fields.Metadata + ctx.HTML(http.StatusUnprocessableEntity, orCtx.NewTemplate) + return + } + + var meta []byte + if fields.Metadata != nil { + meta, err = json.Marshal(fields.Metadata) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + } + + w.Meta = string(meta) + + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := webhook.UpdateWebhook(ctx, 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)) +} + +func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) { + orCtx, err := getOwnerRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOwnerRepoCtx", err) + return nil, nil + } + ctx.Data["BaseLink"] = orCtx.Link + ctx.Data["BaseLinkNew"] = orCtx.LinkNew + ctx.Data["WebhookList"] = webhook_service.List() + + var w *webhook.Webhook + if orCtx.RepoID > 0 { + w, err = webhook.GetWebhookByRepoID(ctx, orCtx.RepoID, ctx.ParamsInt64(":id")) + } else if orCtx.OwnerID > 0 { + w, err = webhook.GetWebhookByOwnerID(ctx, orCtx.OwnerID, ctx.ParamsInt64(":id")) + } else if orCtx.IsAdmin { + w, err = webhook.GetSystemOrDefaultWebhook(ctx, ctx.ParamsInt64(":id")) + } + if err != nil || w == nil { + if webhook.IsErrWebhookNotExist(err) { + ctx.NotFound("GetWebhookByID", nil) + } else { + ctx.ServerError("GetWebhookByID", err) + } + return nil, nil + } + + ctx.Data["HookType"] = w.Type + + if handler := webhook_service.GetWebhookHandler(w.Type); handler != nil { + ctx.Data["HookMetadata"] = handler.Metadata(w) + ctx.Data["WebhookHandler"] = handler + } + + ctx.Data["History"], err = w.History(ctx, 1) + if err != nil { + ctx.ServerError("History", err) + } + return orCtx, w +} + +// WebhookEdit render editing web hook page +func WebhookEdit(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) +} + +// WebhookTest test if web hook is work fine +func WebhookTest(ctx *context.Context) { + hookID := ctx.ParamsInt64(":id") + w, err := webhook.GetWebhookByRepoID(ctx, ctx.Repo.Repository.ID, hookID) + if err != nil { + ctx.Flash.Error("GetWebhookByRepoID: " + err.Error()) + ctx.Status(http.StatusInternalServerError) + return + } + + // Grab latest commit or fake one if it's empty repository. + commit := ctx.Repo.Commit + if commit == nil { + ghost := user_model.NewGhostUser() + objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName) + commit = &git.Commit{ + ID: objectFormat.EmptyObjectID(), + Author: ghost.NewGitSig(), + Committer: ghost.NewGitSig(), + CommitMessage: "This is a fake commit", + } + } + + apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone) + + apiCommit := &api.PayloadCommit{ + ID: commit.ID.String(), + Message: commit.Message(), + URL: ctx.Repo.Repository.HTMLURL() + "/commit/" + url.PathEscape(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, + }, + } + + commitID := commit.ID.String() + p := &api.PushPayload{ + Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch, + Before: commitID, + After: commitID, + CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID), + Commits: []*api.PayloadCommit{apiCommit}, + TotalCommits: 1, + HeadCommit: apiCommit, + Repo: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), + Pusher: apiUser, + Sender: apiUser, + } + if err := webhook_service.PrepareWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil { + ctx.Flash.Error("PrepareWebhook: " + err.Error()) + ctx.Status(http.StatusInternalServerError) + } else { + ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success")) + ctx.Status(http.StatusOK) + } +} + +// WebhookReplay replays a webhook +func WebhookReplay(ctx *context.Context) { + hookTaskUUID := ctx.Params(":uuid") + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + + if err := webhook_service.ReplayHookTask(ctx, w, hookTaskUUID); err != nil { + if webhook.IsErrHookTaskNotExist(err) { + ctx.NotFound("ReplayHookTask", nil) + } else { + ctx.ServerError("ReplayHookTask", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.webhook.delivery.success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// WebhookDelete delete a webhook +func WebhookDelete(ctx *context.Context) { + if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/hooks") +} diff --git a/routers/web/repo/topic.go b/routers/web/repo/topic.go new file mode 100644 index 0000000..d81a695 --- /dev/null +++ b/routers/web/repo/topic.go @@ -0,0 +1,60 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +// TopicsPost response for creating repository +func TopicsPost(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]any{ + "message": "Only owners could change the topics.", + }) + return + } + + topics := make([]string, 0) + topicsStr := ctx.FormTrim("topics") + if len(topicsStr) > 0 { + topics = strings.Split(topicsStr, ",") + } + + validTopics, invalidTopics := repo_model.SanitizeAndValidateTopics(topics) + + if len(validTopics) > 25 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": nil, + "message": ctx.Tr("repo.topic.count_prompt"), + }) + return + } + + if len(invalidTopics) > 0 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": invalidTopics, + "message": ctx.Tr("repo.topic.format_prompt"), + }) + return + } + + err := repo_model.SaveTopics(ctx, ctx.Repo.Repository.ID, validTopics...) + if err != nil { + log.Error("SaveTopics failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "message": "Save topics failed.", + }) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "status": "ok", + }) +} diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go new file mode 100644 index 0000000..d11af46 --- /dev/null +++ b/routers/web/repo/treelist.go @@ -0,0 +1,54 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" + + "github.com/go-enry/go-enry/v2" +) + +// TreeList get all files' entries of a repository +func TreeList(ctx *context.Context) { + tree, err := ctx.Repo.Commit.SubTree("/") + if err != nil { + ctx.ServerError("Repo.Commit.SubTree", err) + return + } + + entries, err := tree.ListEntriesRecursiveFast() + if err != nil { + ctx.ServerError("ListEntriesRecursiveFast", err) + return + } + entries.CustomSort(base.NaturalSortLess) + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if !isExcludedEntry(entry) { + files = append(files, entry.Name()) + } + } + ctx.JSON(http.StatusOK, files) +} + +func isExcludedEntry(entry *git.TreeEntry) bool { + if entry.IsDir() { + return true + } + + if entry.IsSubModule() { + return true + } + + if enry.IsVendor(entry.Name()) { + return true + } + + return false +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go new file mode 100644 index 0000000..c739b29 --- /dev/null +++ b/routers/web/repo/view.go @@ -0,0 +1,1258 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + gocontext "context" + "encoding/base64" + "errors" + "fmt" + "html/template" + "image" + "io" + "net/http" + "net/url" + "path" + "slices" + "strings" + "time" + + _ "image/gif" // for processing gif images + _ "image/jpeg" // for processing jpeg images + _ "image/png" // for processing png images + + activities_model "code.gitea.io/gitea/models/activities" + admin_model "code.gitea.io/gitea/models/admin" + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issue_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/svg" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + files_service "code.gitea.io/gitea/services/repository/files" + + "github.com/nektos/act/pkg/model" + + _ "golang.org/x/image/bmp" // for processing bmp images + _ "golang.org/x/image/webp" // for processing webp images +) + +const ( + tplRepoEMPTY base.TplName = "repo/empty" + tplRepoHome base.TplName = "repo/home" + tplRepoViewList base.TplName = "repo/view_list" + tplWatchers base.TplName = "repo/watchers" + tplForks base.TplName = "repo/forks" + tplMigrating base.TplName = "repo/migrate/migrating" +) + +// locate a README for a tree in one of the supported paths. +// +// entries is passed to reduce calls to ListEntries(), so +// this has precondition: +// +// entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries() +// +// FIXME: There has to be a more efficient way of doing this +func FindReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { + // Create a list of extensions in priority order + // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md + // 2. Org-Mode files - with and without localisation - e.g. README.en-us.org or README.org + // 3. Txt files - e.g. README.txt + // 4. No extension - e.g. README + exts := append(append(localizedExtensions(".md", ctx.Locale.Language()), localizedExtensions(".org", ctx.Locale.Language())...), ".txt", "") // sorted by priority + extCount := len(exts) + readmeFiles := make([]*git.TreeEntry, extCount+1) + + docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/) + for _, entry := range entries { + if tryWellKnownDirs && entry.IsDir() { + // as a special case for the top-level repo introduction README, + // fall back to subfolders, looking for e.g. docs/README.md, .gitea/README.zh-CN.txt, .github/README.txt, ... + // (note that docsEntries is ignored unless we are at the root) + lowerName := strings.ToLower(entry.Name()) + switch lowerName { + case "docs": + if entry.Name() == "docs" || docsEntries[0] == nil { + docsEntries[0] = entry + } + case ".forgejo": + if entry.Name() == ".forgejo" || docsEntries[1] == nil { + docsEntries[1] = 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 + } + if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok { + log.Debug("Potential readme file: %s", entry.Name()) + if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { + if entry.IsLink() { + target, _, err := entry.FollowLinks() + if err != nil && !git.IsErrBadLink(err) { + return "", nil, err + } else if target != nil && (target.IsExecutable() || target.IsRegular()) { + readmeFiles[i] = entry + } + } else { + readmeFiles[i] = entry + } + } + } + } + var readmeFile *git.TreeEntry + for _, f := range readmeFiles { + if f != nil { + readmeFile = f + break + } + } + + if ctx.Repo.TreePath == "" && readmeFile == nil { + for _, subTreeEntry := range docsEntries { + if subTreeEntry == nil { + continue + } + subTree := subTreeEntry.Tree() + if subTree == nil { + // this should be impossible; if subTreeEntry exists so should this. + continue + } + var err error + childEntries, err := subTree.ListEntries() + if err != nil { + return "", nil, err + } + + subfolder, readmeFile, err := FindReadmeFileInEntries(ctx, childEntries, false) + if err != nil && !git.IsErrNotExist(err) { + return "", nil, err + } + if readmeFile != nil { + return path.Join(subTreeEntry.Name(), subfolder), readmeFile, nil + } + } + } + + return "", readmeFile, nil +} + +func renderDirectory(ctx *context.Context) { + entries := renderDirectoryFiles(ctx, 1*time.Second) + if ctx.Written() { + return + } + + if ctx.Repo.TreePath != "" { + ctx.Data["HideRepoInfo"] = true + ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefName) + } + + subfolder, readmeFile, err := FindReadmeFileInEntries(ctx, entries, true) + if err != nil { + ctx.ServerError("findReadmeFileInEntries", err) + return + } + + renderReadmeFile(ctx, subfolder, readmeFile) +} + +// localizedExtensions prepends the provided language code with and without a +// regional identifier to the provided extension. +// Note: the language code will always be lower-cased, if a region is present it must be separated with a `-` +// Note: ext should be prefixed with a `.` +func localizedExtensions(ext, languageCode string) (localizedExts []string) { + if len(languageCode) < 1 { + return []string{ext} + } + + lowerLangCode := "." + strings.ToLower(languageCode) + + if strings.Contains(lowerLangCode, "-") { + underscoreLangCode := strings.ReplaceAll(lowerLangCode, "-", "_") + indexOfDash := strings.Index(lowerLangCode, "-") + // e.g. [.zh-cn.md, .zh_cn.md, .zh.md, _zh.md, .md] + return []string{lowerLangCode + ext, underscoreLangCode + ext, lowerLangCode[:indexOfDash] + ext, "_" + lowerLangCode[1:indexOfDash] + ext, ext} + } + + // e.g. [.en.md, .md] + return []string{lowerLangCode + ext, ext} +} + +type fileInfo struct { + isTextFile bool + isLFSFile bool + fileSize int64 + lfsMeta *lfs.Pointer + st typesniffer.SniffedType +} + +func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, io.ReadCloser, *fileInfo, error) { + dataRc, err := blob.DataAsync() + if err != nil { + return nil, nil, nil, err + } + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(dataRc, buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + isTextFile := st.IsText() + + // FIXME: what happens when README file is an image? + if !isTextFile || !setting.LFS.StartServer { + return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + } + + pointer, _ := lfs.ReadPointerFromBuffer(buf) + if !pointer.IsValid() { // fallback to plain file + return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + } + + meta, err := git_model.GetLFSMetaObjectByOid(ctx, repoID, pointer.Oid) + if err != nil { // fallback to plain file + log.Warn("Unable to access LFS pointer %s in repo %d: %v", pointer.Oid, repoID, err) + return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + } + + dataRc.Close() + + dataRc, err = lfs.ReadMetaObject(pointer) + if err != nil { + return nil, nil, nil, err + } + + buf = make([]byte, 1024) + n, err = util.ReadAtMost(dataRc, buf) + if err != nil { + dataRc.Close() + return nil, nil, nil, err + } + buf = buf[:n] + + st = typesniffer.DetectContentType(buf) + + return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil +} + +func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { + target := readmeFile + if readmeFile != nil && readmeFile.IsLink() { + target, _, _ = readmeFile.FollowLinks() + } + if target == nil { + // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't) + // simply skip rendering the README + return + } + + ctx.Data["RawFileLink"] = "" + ctx.Data["ReadmeInList"] = true + ctx.Data["ReadmeExist"] = true + ctx.Data["FileIsSymlink"] = readmeFile.IsLink() + + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob()) + if err != nil { + ctx.ServerError("getFileReader", err) + return + } + defer dataRc.Close() + + ctx.Data["FileIsText"] = fInfo.isTextFile + ctx.Data["FileName"] = path.Join(subfolder, readmeFile.Name()) + ctx.Data["IsLFSFile"] = fInfo.isLFSFile + + if fInfo.isLFSFile { + filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name())) + ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64)) + } + + if !fInfo.isTextFile { + return + } + + if fInfo.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"] = fInfo.fileSize + return + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + if markupType := markup.Type(readmeFile.Name()); markupType != "" { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ + Ctx: ctx, + RelativePath: path.Join(ctx.Repo.TreePath, readmeFile.Name()), // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Join(ctx.Repo.TreePath, subfolder), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + }, rd) + if err != nil { + log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) + delete(ctx.Data, "IsMarkup") + } + } + + if ctx.Data["IsMarkup"] != true { + ctx.Data["IsPlainText"] = true + content, err := io.ReadAll(rd) + if err != nil { + log.Error("Read readme content failed: %v", err) + } + contentEscaped := template.HTMLEscapeString(util.UnsafeBytesToString(content)) + ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale, charset.FileviewContext) + } + + if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + ctx.Data["CanEditReadmeFile"] = true + } +} + +func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { + // Show latest commit info of repository in table header, + // or of directory if not in root directory. + ctx.Data["LatestCommit"] = latestCommit + if latestCommit != nil { + verification := asymkey_model.ParseCommitWithSignature(ctx, latestCommit) + + if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { + return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) + }, nil); err != nil { + ctx.ServerError("CalculateTrustStatus", err) + return false + } + ctx.Data["LatestCommitVerification"] = verification + ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) + + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + if !ctx.Repo.CanRead(unit_model.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, statuses) + } + + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(statuses) + ctx.Data["LatestCommitStatuses"] = statuses + } + + return true +} + +func renderFile(ctx *context.Context, entry *git.TreeEntry) { + ctx.Data["IsViewFile"] = true + ctx.Data["HideRepoInfo"] = true + blob := entry.Blob() + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) + if err != nil { + ctx.ServerError("getFileReader", err) + return + } + defer dataRc.Close() + + ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefName) + ctx.Data["FileIsSymlink"] = entry.IsLink() + ctx.Data["FileName"] = blob.Name() + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + + if entry.IsLink() { + _, link, err := entry.FollowLinks() + // Errors should be allowed, because this shouldn't + // block rendering invalid symlink files. + if err == nil { + ctx.Data["SymlinkURL"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(link) + } + } + + commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + + if !loadLatestCommitData(ctx, commit) { + return + } + + if ctx.Repo.TreePath == ".editorconfig" { + _, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit) + if editorconfigWarning != nil { + ctx.Data["FileWarning"] = strings.TrimSpace(editorconfigWarning.Error()) + } + if editorconfigErr != nil { + ctx.Data["FileError"] = strings.TrimSpace(editorconfigErr.Error()) + } + } else if issue_service.IsTemplateConfig(ctx.Repo.TreePath) { + _, issueConfigErr := issue_service.GetTemplateConfig(ctx.Repo.GitRepo, ctx.Repo.TreePath, ctx.Repo.Commit) + if issueConfigErr != nil { + ctx.Data["FileError"] = strings.TrimSpace(issueConfigErr.Error()) + } + } else if actions.IsWorkflow(ctx.Repo.TreePath) { + content, err := actions.GetContentFromEntry(entry) + if err != nil { + log.Error("actions.GetContentFromEntry: %v", err) + } + _, workFlowErr := model.ReadWorkflow(bytes.NewReader(content)) + if workFlowErr != nil { + ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) + } + } else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) { + if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil { + _, warnings := issue_model.GetCodeOwnersFromContent(ctx, data) + if len(warnings) > 0 { + ctx.Data["FileWarning"] = strings.Join(warnings, "\n") + } + } + } + + isDisplayingSource := ctx.FormString("display") == "source" + isDisplayingRendered := !isDisplayingSource + + if fInfo.isLFSFile { + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } + + isRepresentableAsText := fInfo.st.IsRepresentableAsText() + if !isRepresentableAsText { + // If we can't show plain text, always try to render. + isDisplayingSource = false + isDisplayingRendered = true + } + ctx.Data["IsLFSFile"] = fInfo.isLFSFile + ctx.Data["FileSize"] = fInfo.fileSize + ctx.Data["IsTextFile"] = fInfo.isTextFile + ctx.Data["IsRepresentableAsText"] = isRepresentableAsText + ctx.Data["IsDisplayingSource"] = isDisplayingSource + ctx.Data["IsDisplayingRendered"] = isDisplayingRendered + ctx.Data["IsExecutable"] = entry.IsExecutable() + + isTextSource := fInfo.isTextFile || isDisplayingSource + ctx.Data["IsTextSource"] = isTextSource + if isTextSource { + ctx.Data["CanCopyContent"] = true + } + + // Check LFS Lock + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + ctx.Data["LFSLock"] = lfsLock + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + if lfsLock != nil { + u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + ctx.Data["LFSLockOwner"] = u.Name + ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() + ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") + } + + // Assume file is not editable first. + if fInfo.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 fInfo.st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true + ctx.Data["HasSourceRenderedToggle"] = true + } + + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + shouldRenderSource := ctx.FormString("display") == "source" + readmeExist := util.IsReadmeFileName(blob.Name()) + ctx.Data["ReadmeExist"] = readmeExist + + markupType := markup.Type(blob.Name()) + // If the markup is detected by custom markup renderer it should not be reset later on + // to not pass it down to the render context. + detected := false + if markupType == "" { + detected = true + markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) + } + if markupType != "" { + ctx.Data["HasSourceRenderedToggle"] = true + } + + if markupType != "" && !shouldRenderSource { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + if !detected { + markupType = "" + } + metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) + metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL() + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ + Ctx: ctx, + Type: markupType, + RelativePath: ctx.Repo.TreePath, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: metas, + GitRepo: ctx.Repo.GitRepo, + }, rd) + if err != nil { + ctx.ServerError("Render", err) + return + } + // to prevent iframe load third-party url + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") + } else { + buf, _ := io.ReadAll(rd) + + // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html + // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; + // Forgejo uses the definition (like most modern editors): + // empty: 0 lines; "a": 1 line; "a\n": 1 line; "a\nb": 2 lines; + // When rendering, the last empty line is not rendered in U and isn't counted towards the number of lines. + // To tell users that the file not contains a trailing EOL, text with a tooltip is displayed in the file header. + // Trailing EOL is only considered if the file has content. + // This NumLines is only used for the display on the UI: "xxx lines" + if len(buf) == 0 { + ctx.Data["NumLines"] = 0 + } else { + hasNoTrailingEOL := !bytes.HasSuffix(buf, []byte{'\n'}) + ctx.Data["HasNoTrailingEOL"] = hasNoTrailingEOL + + numLines := bytes.Count(buf, []byte{'\n'}) + if hasNoTrailingEOL { + numLines++ + } + ctx.Data["NumLines"] = numLines + } + ctx.Data["NumLinesSet"] = true + + language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + if err != nil { + log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) + } + + fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) + ctx.Data["LexerName"] = lexerName + if err != nil { + log.Error("highlight.File failed, fallback to plain text: %v", err) + fileContent = highlight.PlainText(buf) + } + status := &charset.EscapeStatus{} + statuses := make([]*charset.EscapeStatus, len(fileContent)) + for i, line := range fileContent { + statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale, charset.FileviewContext) + status = status.Or(statuses[i]) + } + ctx.Data["EscapeStatus"] = status + ctx.Data["FileContent"] = fileContent + ctx.Data["LineEscapeStatus"] = statuses + } + if !fInfo.isLFSFile { + if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.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.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") + } + } + + case fInfo.st.IsPDF(): + ctx.Data["IsPDFFile"] = true + case fInfo.st.IsVideo(): + ctx.Data["IsVideoFile"] = true + case fInfo.st.IsAudio(): + ctx.Data["IsAudioFile"] = true + case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): + ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true + default: + if fInfo.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 + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ + Ctx: ctx, + RelativePath: ctx.Repo.TreePath, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + }, rd) + if err != nil { + ctx.ServerError("Render", err) + return + } + } + } + + if ctx.Repo.GitRepo != nil { + attrs, err := ctx.Repo.GitRepo.GitAttributes(ctx.Repo.CommitID, ctx.Repo.TreePath, "linguist-vendored", "linguist-generated") + if err != nil { + log.Error("GitAttributes(%s, %s) failed: %v", ctx.Repo.CommitID, ctx.Repo.TreePath, err) + } else { + ctx.Data["IsVendored"] = attrs["linguist-vendored"].Bool().Value() + ctx.Data["IsGenerated"] = attrs["linguist-generated"].Bool().Value() + } + } + + if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { + img, _, err := image.DecodeConfig(bytes.NewReader(buf)) + if err == nil { + // There are Image formats go can't decode + // Instead of throwing an error in that case, we show the size only when we can decode + ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) + } + } + + if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.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.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") + } +} + +func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output template.HTML, err error) { + markupRd, markupWr := io.Pipe() + defer markupWr.Close() + done := make(chan struct{}) + go func() { + sb := &strings.Builder{} + // We allow NBSP here this is rendered + escaped, _ = charset.EscapeControlReader(markupRd, sb, ctx.Locale, charset.FileviewContext, charset.RuneNBSP) + output = template.HTML(sb.String()) + close(done) + }() + err = markup.Render(renderCtx, input, markupWr) + _ = markupWr.CloseWithError(err) + <-done + return escaped, output, err +} + +func checkHomeCodeViewable(ctx *context.Context) { + if len(ctx.Repo.Units) > 0 { + if ctx.Repo.Repository.IsBeingCreated() { + task, err := admin_model.GetMigratingTask(ctx, ctx.Repo.Repository.ID) + if err != nil { + if admin_model.IsErrTaskDoesNotExist(err) { + ctx.Data["Repo"] = ctx.Repo + ctx.Data["CloneAddr"] = "" + ctx.Data["Failed"] = true + ctx.HTML(http.StatusOK, tplMigrating) + return + } + 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"], _ = util.SanitizeURL(cfg.CloneAddr) + ctx.Data["Failed"] = task.Status == structs.TaskStatusFailed + ctx.HTML(http.StatusOK, tplMigrating) + return + } + + if ctx.IsSigned { + // Set repo notification-status read if unread + if err := activities_model.SetRepoReadBy(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("ReadBy", err) + return + } + } + + var firstUnit *unit_model.Unit + for _, repoUnit := range ctx.Repo.Units { + if repoUnit.Type == unit_model.TypeCode { + return + } + + unit, ok := unit_model.Units[repoUnit.Type] + if ok && (firstUnit == nil || !firstUnit.IsLessThan(unit)) && repoUnit.Type.CanBeDefault() { + firstUnit = &unit + } + } + + if firstUnit != nil { + ctx.Redirect(ctx.Repo.Repository.Link() + firstUnit.URI) + return + } + } + + ctx.NotFound("Home", errors.New(ctx.Locale.TrString("units.error.no_unit_allowed_repo"))) +} + +func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) { + if entry.Name() != "" { + return + } + tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.SubTree", err) + return + } + allEntries, err := tree.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return + } + for _, entry := range allEntries { + if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" { + // Read Citation file contents + if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("checkCitationFile: GetBlobContent: %v", err) + } else { + ctx.Data["CitationExist"] = true + ctx.Data["CitationFile"] = entry.Name() + ctx.PageData["citationFileContent"] = content + break + } + } + } +} + +// Home render repository home page +func Home(ctx *context.Context) { + if setting.Other.EnableFeed { + isFeed, _, showFeedType := feed.GetFeedType(ctx.Params(":reponame"), ctx.Req) + if isFeed { + if ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType) { + feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType) + return + } + + if ctx.Repo.Repository.IsEmpty { + ctx.NotFound("MustBeNotEmpty", nil) + return + } + + if ctx.Repo.TreePath == "" { + feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType) + } else { + feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType) + } + return + } + } + + checkHomeCodeViewable(ctx) + if ctx.Written() { + return + } + + renderHomeCode(ctx) +} + +// LastCommit returns lastCommit data for the provided branch/tag/commit and directory (in url) and filenames in body +func LastCommit(ctx *context.Context) { + checkHomeCodeViewable(ctx) + if ctx.Written() { + return + } + + renderDirectoryFiles(ctx, 0) + 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] + } + } + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["BranchLink"] = branchLink + + ctx.HTML(http.StatusOK, tplRepoViewList) +} + +func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entries { + tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.SubTree", err) + return nil + } + + ctx.Data["LastCommitLoaderURL"] = ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return nil + } + + if !entry.IsDir() { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return nil + } + + allEntries, err := tree.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return nil + } + allEntries.CustomSort(base.NaturalSortLess) + + commitInfoCtx := gocontext.Context(ctx) + if timeout > 0 { + var cancel gocontext.CancelFunc + commitInfoCtx, cancel = gocontext.WithTimeout(ctx, timeout) + defer cancel() + } + + files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.Commit, ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetCommitsInfo", err) + return nil + } + ctx.Data["Files"] = files + for _, f := range files { + if f.Commit == nil { + ctx.Data["HasFilesWithoutLatestCommit"] = true + break + } + } + + if !loadLatestCommitData(ctx, latestCommit) { + return nil + } + + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + + if len(ctx.Repo.TreePath) > 0 { + treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } + + ctx.Data["TreeLink"] = treeLink + ctx.Data["SSHDomain"] = setting.SSH.Domain + + return allEntries +} + +func renderLanguageStats(ctx *context.Context) { + langs, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5) + if err != nil { + ctx.ServerError("Repo.GetTopLanguageStats", err) + return + } + + ctx.Data["LanguageStats"] = langs +} + +func renderRepoTopics(ctx *context.Context) { + topics, _, err := repo_model.FindTopics(ctx, &repo_model.FindTopicOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("models.FindTopics", err) + return + } + ctx.Data["Topics"] = topics +} + +func prepareOpenWithEditorApps(ctx *context.Context) { + var tmplApps []map[string]any + apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx) + if len(apps) == 0 { + apps = setting.DefaultOpenWithEditorApps() + } + for _, app := range apps { + schema, _, _ := strings.Cut(app.OpenURL, ":") + var iconHTML template.HTML + if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" { + iconHTML = svg.RenderHTML(fmt.Sprintf("gitea-open-with-%s", schema), 16, "tw-mr-2") + } else { + iconHTML = svg.RenderHTML("gitea-git", 16, "tw-mr-2") // TODO: it could support user's customized icon in the future + } + tmplApps = append(tmplApps, map[string]any{ + "DisplayName": app.DisplayName, + "OpenURL": app.OpenURL, + "IconHTML": iconHTML, + }) + } + ctx.Data["OpenWithEditorApps"] = tmplApps +} + +func renderHomeCode(ctx *context.Context) { + ctx.Data["PageIsViewCode"] = true + ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled + prepareOpenWithEditorApps(ctx) + + if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { + showEmpty := true + var err error + if ctx.Repo.GitRepo != nil { + showEmpty, err = ctx.Repo.GitRepo.IsEmpty() + if err != nil { + log.Error("GitRepo.IsEmpty: %v", err) + ctx.Repo.Repository.Status = repo_model.RepositoryBroken + showEmpty = true + ctx.Flash.Error(ctx.Tr("error.occurred"), true) + } + } + if showEmpty { + ctx.HTML(http.StatusOK, tplRepoEMPTY) + return + } + + // the repo is not really empty, so we should update the modal in database + // such problem may be caused by: + // 1) an error occurs during pushing/receiving. 2) the user replaces an empty git repo manually + // and even more: the IsEmpty flag is deeply broken and should be removed with the UI changed to manage to cope with empty repos. + // it's possible for a repository to be non-empty by that flag but still 500 + // because there are no branches - only tags -or the default branch is non-extant as it has been 0-pushed. + ctx.Repo.Repository.IsEmpty = false + if err = repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty"); err != nil { + ctx.ServerError("UpdateRepositoryCols", err) + return + } + if err = repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil { + ctx.ServerError("UpdateRepoSize", err) + return + } + + // the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values + link := ctx.Link + if ctx.Req.URL.RawQuery != "" { + link += "?" + ctx.Req.URL.RawQuery + } + ctx.Redirect(link) + 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 + + // 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 { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return + } + + checkOutdatedBranch(ctx) + + checkCitationFile(ctx, entry) + if ctx.Written() { + return + } + + renderLanguageStats(ctx) + if ctx.Written() { + return + } + + if entry.IsDir() { + renderDirectory(ctx) + } else { + renderFile(ctx, entry) + } + if ctx.Written() { + return + } + + if ctx.Doer != nil { + if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + + // If the repo is a mirror, don't display recently pushed branches. + if ctx.Repo.Repository.IsMirror { + goto PostRecentBranchCheck + } + + // If pull requests aren't enabled for either the current repo, or its + // base, don't display recently pushed branches. + if !(ctx.Repo.Repository.AllowsPulls(ctx) || + (ctx.Repo.Repository.BaseRepo != nil && ctx.Repo.Repository.BaseRepo.AllowsPulls(ctx))) { + goto PostRecentBranchCheck + } + + // Find recently pushed new branches to *this* repo. + branches, err := git_model.FindRecentlyPushedNewBranches(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID, ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.ServerError("FindRecentlyPushedBranches", err) + return + } + + // If this is not a fork, check if the signed in user has a fork, and + // check branches there. + if !ctx.Repo.Repository.IsFork { + repo := repo_model.GetForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + if repo != nil { + baseBranches, err := git_model.FindRecentlyPushedNewBranches(ctx, repo.ID, ctx.Doer.ID, repo.DefaultBranch) + if err != nil { + ctx.ServerError("FindRecentlyPushedBranches", err) + return + } + branches = append(branches, baseBranches...) + } + } + + // Filter out branches that have no relation to the default branch of + // the repository. + var filteredBranches []*git_model.Branch + for _, branch := range branches { + repo, err := branch.GetRepo(ctx) + if err != nil { + continue + } + gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + if err != nil { + continue + } + defer gitRepo.Close() + head, err := gitRepo.GetCommit(branch.CommitID) + if err != nil { + continue + } + defaultBranch, err := gitrepo.GetDefaultBranch(ctx, repo) + if err != nil { + continue + } + defaultBranchHead, err := gitRepo.GetCommit(defaultBranch) + if err != nil { + continue + } + + hasMergeBase, err := head.HasPreviousCommit(defaultBranchHead.ID) + if err != nil { + continue + } + + if hasMergeBase { + filteredBranches = append(filteredBranches, branch) + } + } + + ctx.Data["RecentlyPushedNewBranches"] = filteredBranches + } + +PostRecentBranchCheck: + 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 + + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + if len(ctx.Repo.TreePath) > 0 { + treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } + ctx.Data["TreeLink"] = treeLink + ctx.Data["TreeNames"] = treeNames + ctx.Data["BranchLink"] = branchLink + ctx.Data["CodeIndexerDisabled"] = !setting.Indexer.RepoIndexerEnabled + ctx.HTML(http.StatusOK, tplRepoHome) +} + +func checkOutdatedBranch(ctx *context.Context) { + if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) { + return + } + + // get the head commit of the branch since ctx.Repo.CommitID is not always the head commit of `ctx.Repo.BranchName` + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranchCommitID: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + dbBranch, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranch: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + if dbBranch.CommitID != commit.ID.String() { + ctx.Flash.Warning(ctx.Tr("repo.error.broken_git_hook", "https://docs.gitea.com/help/faq#push-hook--webhook--actions-arent-running"), true) + } +} + +// RenderUserCards render a page show users according the input template +func RenderUserCards(ctx *context.Context, total int, getter func(opts db.ListOptions) ([]*user_model.User, error), tpl base.TplName) { + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + pager := context.NewPagination(total, setting.MaxUserCardsPerPage, page, 5) + ctx.Data["Page"] = pager + + items, err := getter(db.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: setting.MaxUserCardsPerPage, + }) + 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, func(opts db.ListOptions) ([]*user_model.User, error) { + return repo_model.GetRepoWatchers(ctx, ctx.Repo.Repository.ID, opts) + }, 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, func(opts db.ListOptions) ([]*user_model.User, error) { + return repo_model.GetStargazers(ctx, ctx.Repo.Repository, opts) + }, tplWatchers) +} + +// Forks render repository's forked users +func Forks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.forks") + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + forks, total, err := repo_model.GetForks(ctx, ctx.Repo.Repository, ctx.Doer, db.ListOptions{ + Page: page, + PageSize: setting.MaxForksPerPage, + }) + if err != nil { + ctx.ServerError("GetForks", err) + return + } + + pager := context.NewPagination(int(total), setting.MaxForksPerPage, page, 5) + ctx.Data["Page"] = pager + + for _, fork := range forks { + if err = fork.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return + } + } + + ctx.Data["Forks"] = forks + + ctx.HTML(http.StatusOK, tplForks) +} diff --git a/routers/web/repo/view_test.go b/routers/web/repo/view_test.go new file mode 100644 index 0000000..73ba118 --- /dev/null +++ b/routers/web/repo/view_test.go @@ -0,0 +1,62 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "reflect" + "testing" +) + +func Test_localizedExtensions(t *testing.T) { + tests := []struct { + name string + ext string + languageCode string + wantLocalizedExts []string + }{ + { + name: "empty language", + ext: ".md", + wantLocalizedExts: []string{".md"}, + }, + { + name: "No region - lowercase", + languageCode: "en", + ext: ".csv", + wantLocalizedExts: []string{".en.csv", ".csv"}, + }, + { + name: "No region - uppercase", + languageCode: "FR", + ext: ".txt", + wantLocalizedExts: []string{".fr.txt", ".txt"}, + }, + { + name: "With region - lowercase", + languageCode: "en-us", + ext: ".md", + wantLocalizedExts: []string{".en-us.md", ".en_us.md", ".en.md", "_en.md", ".md"}, + }, + { + name: "With region - uppercase", + languageCode: "en-CA", + ext: ".MD", + wantLocalizedExts: []string{".en-ca.MD", ".en_ca.MD", ".en.MD", "_en.MD", ".MD"}, + }, + { + name: "With region - all uppercase", + languageCode: "ZH-TW", + ext: ".md", + wantLocalizedExts: []string{".zh-tw.md", ".zh_tw.md", ".zh.md", "_zh.md", ".md"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotLocalizedExts := localizedExtensions(tt.ext, tt.languageCode); !reflect.DeepEqual(gotLocalizedExts, tt.wantLocalizedExts) { + t.Errorf("localizedExtensions() = %v, want %v", gotLocalizedExts, tt.wantLocalizedExts) + } + }) + } +} diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go new file mode 100644 index 0000000..1fd0800 --- /dev/null +++ b/routers/web/repo/wiki.go @@ -0,0 +1,816 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "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/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + notify_service "code.gitea.io/gitea/services/notify" + 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" + tplWikiSearch base.TplName = "repo/wiki/search" +) + +// MustEnableWiki check if wiki is enabled, if external then redirect +func MustEnableWiki(ctx *context.Context) { + if !ctx.Repo.CanRead(unit.TypeWiki) && + !ctx.Repo.CanRead(unit.TypeExternalWiki) { + 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.Doer, + unit.TypeWiki, + unit.TypeExternalWiki, + ctx.Repo.Repository, + ctx.Repo.Permission) + } + ctx.NotFound("MustEnableWiki", nil) + return + } + + unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalWiki) + if err == nil { + ctx.Redirect(unit.ExternalWikiConfig().ExternalWikiURL) + return + } +} + +// PageMeta wiki page meta information +type PageMeta struct { + Name string + SubURL string + GitEntryName 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, the 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 := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil, nil, err + } + + commit, err := wikiRepo.GetBranchCommit(ctx.Repo.Repository.GetWikiBranchName()) + 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 := io.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 wiki_service.WebPath) ([]byte, *git.TreeEntry, string, bool) { + gitFilename := wiki_service.WebPathToGitPath(wikiName) + entry, err := findEntryForFile(commit, gitFilename) + 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, gitFilename, false +} + +func renderViewPage(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 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.GitPathToWebPath(entry.Name()) + if err != nil { + if repo_model.IsErrWikiInvalidFileName(err) { + continue + } + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("WikiFilenameToName", err) + return nil, nil + } else if wikiName == "_Sidebar" || wikiName == "_Footer" { + continue + } + _, displayName := wiki_service.WebPathToUserTitle(wikiName) + pages = append(pages, PageMeta{ + Name: displayName, + SubURL: wiki_service.WebPathToURLPath(wikiName), + GitEntryName: entry.Name(), + }) + } + ctx.Data["Pages"] = pages + + // get requested page name + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(pageName) == 0 { + pageName = "Home" + } + + _, displayName := wiki_service.WebPathToUserTitle(pageName) + ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName) + ctx.Data["old_title"] = displayName + ctx.Data["Title"] = displayName + ctx.Data["title"] = displayName + + isSideBar := pageName == "_Sidebar" + isFooter := pageName == "_Footer" + + // 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/?action=_pages") + } + if entry == nil || ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + + var sidebarContent []byte + if !isSideBar { + sidebarContent, _, _, _ = wikiContentsByName(ctx, commit, "_Sidebar") + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + } else { + sidebarContent = data + } + + var footerContent []byte + if !isFooter { + footerContent, _, _, _ = wikiContentsByName(ctx, commit, "_Footer") + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + } else { + footerContent = data + } + + rctx := &markup.RenderContext{ + Ctx: ctx, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + IsWiki: true, + } + buf := &strings.Builder{} + + renderFn := func(data []byte) (escaped *charset.EscapeStatus, output string, err error) { + markupRd, markupWr := io.Pipe() + defer markupWr.Close() + done := make(chan struct{}) + go func() { + // We allow NBSP here this is rendered + escaped, _ = charset.EscapeControlReader(markupRd, buf, ctx.Locale, charset.WikiContext, charset.RuneNBSP) + output = buf.String() + buf.Reset() + close(done) + }() + + err = markdown.Render(rctx, bytes.NewReader(data), markupWr) + _ = markupWr.CloseWithError(err) + <-done + return escaped, output, err + } + + ctx.Data["EscapeStatus"], ctx.Data["content"], err = renderFn(data) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("Render", err) + return nil, nil + } + + if rctx.SidebarTocNode != nil { + sb := &strings.Builder{} + err = markdown.SpecializedMarkdown().Renderer().Render(sb, nil, rctx.SidebarTocNode) + if err != nil { + log.Error("Failed to render wiki sidebar TOC: %v", err) + } else { + ctx.Data["sidebarTocContent"] = sb.String() + } + } + + if !isSideBar { + buf.Reset() + ctx.Data["sidebarEscapeStatus"], ctx.Data["sidebarContent"], err = renderFn(sidebarContent) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("Render", err) + return nil, nil + } + ctx.Data["sidebarPresent"] = sidebarContent != nil + } else { + ctx.Data["sidebarPresent"] = false + } + + if !isFooter { + buf.Reset() + ctx.Data["footerEscapeStatus"], ctx.Data["footerContent"], err = renderFn(footerContent) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("Render", err) + return nil, nil + } + ctx.Data["footerPresent"] = footerContent != nil + } else { + ctx.Data["footerPresent"] = false + } + + // get commit count - wiki revisions + commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.GetWikiBranchName(), 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.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(pageName) == 0 { + pageName = "Home" + } + + _, displayName := wiki_service.WebPathToUserTitle(pageName) + ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName) + ctx.Data["old_title"] = displayName + ctx.Data["Title"] = displayName + ctx.Data["title"] = displayName + + 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/?action=_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(ctx.Repo.Repository.GetWikiBranchName(), pageFilename) + ctx.Data["CommitCount"] = commitsCount + + // get page + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + // get Commit Count + commitsHistory, err := wikiRepo.CommitsByFileAndRange( + git.CommitsByFileAndRangeOptions{ + Revision: ctx.Repo.Repository.GetWikiBranchName(), + File: pageFilename, + Page: page, + }) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("CommitsByFileAndRange", err) + return nil, nil + } + ctx.Data["Commits"] = git_model.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository) + + pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParamString("action", "_revision") + 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.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(pageName) == 0 { + pageName = "Home" + } + + _, displayName := wiki_service.WebPathToUserTitle(pageName) + ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName) + ctx.Data["old_title"] = displayName + ctx.Data["Title"] = displayName + ctx.Data["title"] = displayName + + // 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/?action=_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"] = "" +} + +// WikiPost renders post of wiki page +func WikiPost(ctx *context.Context) { + switch ctx.FormString("action") { + case "_new": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + NewWikiPost(ctx) + return + case "_delete": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + DeleteWikiPagePost(ctx) + return + } + + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + EditWikiPost(ctx) +} + +// Wiki renders single wiki page +func Wiki(ctx *context.Context) { + ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + + switch ctx.FormString("action") { + case "_pages": + WikiPages(ctx) + return + case "_revision": + WikiRevision(ctx) + return + case "_edit": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + EditWiki(ctx) + return + case "_new": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + NewWiki(ctx) + return + } + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + wikiRepo, entry := renderViewPage(ctx) + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + if ctx.Written() { + return + } + 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["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !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) + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + if ctx.Written() { + return + } + 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["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + return + } + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + entries, err := commit.ListEntries() + if err != nil { + 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 { + ctx.ServerError("GetCommit", err) + return + } + wikiName, err := wiki_service.GitPathToWebPath(entry.Name()) + if err != nil { + if repo_model.IsErrWikiInvalidFileName(err) { + continue + } + ctx.ServerError("WikiFilenameToName", err) + return + } + _, displayName := wiki_service.WebPathToUserTitle(wikiName) + pages = append(pages, PageMeta{ + Name: displayName, + SubURL: wiki_service.WebPathToURLPath(wikiName), + GitEntryName: entry.Name(), + UpdatedUnix: timeutil.TimeStamp(c.Author.When.Unix()), + }) + } + ctx.Data["Pages"] = pages + + 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) + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("findEntryForFile", nil) + return + } + ctx.ServerError("findEntryForfile", err) + return + } + + providedWebPath := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + providedGitPath := wiki_service.WebPathToGitPath(providedWebPath) + var entry *git.TreeEntry + if commit != nil { + // Try to find a file with that name + entry, err = findEntryForFile(commit, providedGitPath) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findFile", err) + return + } + + if entry == nil { + // Try to find a wiki page with that name + providedGitPath = strings.TrimSuffix(providedGitPath, ".md") + entry, err = findEntryForFile(commit, providedGitPath) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findFile", err) + return + } + } + } + + if entry != nil { + if err = common.ServeBlob(ctx.Base, ctx.Repo.TreePath, entry.Blob(), nil); 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") + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["title"] = "Home" + } + if ctx.FormString("title") != "" { + ctx.Data["title"] = ctx.FormString("title") + } + + 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") + + 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.UserTitleToWebPath("", form.Title) + + if len(form.Message) == 0 { + form.Message = ctx.Locale.TrString("repo.editor.add", form.Title) + } + + if err := wiki_service.AddWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.Content, form.Message); err != nil { + if repo_model.IsErrWikiReservedName(err) { + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.wiki.reserved_page", wikiName), tplWikiNew, &form) + } else if repo_model.IsErrWikiAlreadyExist(err) { + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.wiki.page_already_exists"), tplWikiNew, &form) + } else { + ctx.ServerError("AddWikiPage", err) + } + return + } + + notify_service.NewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message) + + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(wikiName)) +} + +// EditWiki render wiki modify page +func EditWiki(ctx *context.Context) { + ctx.Data["PageIsWikiEdit"] = 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") + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplWikiNew) + return + } + + oldWikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + newWikiName := wiki_service.UserTitleToWebPath("", form.Title) + + if len(form.Message) == 0 { + form.Message = ctx.Locale.TrString("repo.editor.update", form.Title) + } + + if err := wiki_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, oldWikiName, newWikiName, form.Content, form.Message); err != nil { + ctx.ServerError("EditWikiPage", err) + return + } + + notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) + + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(newWikiName)) +} + +// DeleteWikiPagePost delete wiki page +func DeleteWikiPagePost(ctx *context.Context) { + wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(wikiName) == 0 { + wikiName = "Home" + } + + if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil { + ctx.ServerError("DeleteWikiPage", err) + return + } + + notify_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName)) + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/wiki/") +} + +func WikiSearchContent(ctx *context.Context) { + keyword := ctx.FormTrim("q") + if keyword == "" { + ctx.HTML(http.StatusOK, tplWikiSearch) + return + } + + res, err := wiki_service.SearchWikiContents(ctx, ctx.Repo.Repository, keyword) + if err != nil { + ctx.ServerError("SearchWikiContents", err) + return + } + + ctx.Data["Results"] = res + ctx.HTML(http.StatusOK, tplWikiSearch) +} diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go new file mode 100644 index 0000000..47bad6d --- /dev/null +++ b/routers/web/repo/wiki_test.go @@ -0,0 +1,224 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "io" + "net/http" + "net/url" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + wiki_service "code.gitea.io/gitea/services/wiki" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + content = "Wiki contents for unit tests" + message = "Wiki commit message for unit tests" +) + +func wikiEntry(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) *git.TreeEntry { + wikiRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer wikiRepo.Close() + commit, err := wikiRepo.GetBranchCommit("master") + require.NoError(t, err) + entries, err := commit.ListEntries() + require.NoError(t, err) + for _, entry := range entries { + if entry.Name() == wiki_service.WebPathToGitPath(wikiName) { + return entry + } + } + return nil +} + +func wikiContent(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) string { + entry := wikiEntry(t, repo, wikiName) + if !assert.NotNil(t, entry) { + return "" + } + reader, err := entry.Blob().DataAsync() + require.NoError(t, err) + defer reader.Close() + bytes, err := io.ReadAll(reader) + require.NoError(t, err) + return string(bytes) +} + +func assertWikiExists(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) { + assert.NotNil(t, wikiEntry(t, repo, wikiName)) +} + +func assertWikiNotExists(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) { + assert.Nil(t, wikiEntry(t, repo, wikiName)) +} + +func assertPagesMetas(t *testing.T, expectedNames []string, metas any) { + 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) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki") + ctx.SetParams("*", "Home") + contexttest.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", "Long Page", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"]) +} + +func TestWikiPages(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_pages") + contexttest.LoadRepo(t, ctx, 1) + WikiPages(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assertPagesMetas(t, []string{"Home", "Long Page", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"]) +} + +func TestNewWiki(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.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", + "&&&&", + } { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: title, + Content: content, + Message: message, + }) + NewWikiPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)) + assert.Equal(t, content, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))) + } +} + +func TestNewWikiPost_ReservedName(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.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) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_edit") + ctx.SetParams("*", "Home") + contexttest.LoadUser(t, ctx, 2) + contexttest.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>", + } { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_new") + ctx.SetParams("*", "Home") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: title, + Content: content, + Message: message, + }) + EditWikiPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)) + assert.Equal(t, content, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))) + if title != "Home" { + assertWikiNotExists(t, ctx.Repo.Repository, "Home") + } + } +} + +func TestDeleteWikiPagePost(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_delete") + contexttest.LoadUser(t, ctx, 2) + contexttest.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": "", // there is no "Page With Spaced Name.md" in repo + "Page-With-Spaced-Name.md": "text/plain; charset=utf-8", + } { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/raw/"+url.PathEscape(filepath)) + ctx.SetParams("*", filepath) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + WikiRaw(ctx) + if filetype == "" { + assert.EqualValues(t, http.StatusNotFound, ctx.Resp.Status(), "filepath: %s", filepath) + } else { + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status(), "filepath: %s", filepath) + assert.EqualValues(t, filetype, ctx.Resp.Header().Get("Content-Type"), "filepath: %s", filepath) + } + } +} diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go new file mode 100644 index 0000000..f389332 --- /dev/null +++ b/routers/web/shared/actions/runners.go @@ -0,0 +1,161 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// RunnersList prepares data for runners list +func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) { + runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts) + if err != nil { + ctx.ServerError("CountRunners", err) + return + } + + if err := actions_model.RunnerList(runners).LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + // ownid=0,repo_id=0,means this token is used for global + var token *actions_model.ActionRunnerToken + token, err = actions_model.GetLatestRunnerToken(ctx, opts.OwnerID, opts.RepoID) + if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) { + token, err = actions_model.NewRunnerToken(ctx, opts.OwnerID, opts.RepoID) + if err != nil { + ctx.ServerError("CreateRunnerToken", err) + return + } + } else if err != nil { + ctx.ServerError("GetLatestRunnerToken", err) + return + } + + ctx.Data["Keyword"] = opts.Filter + ctx.Data["Runners"] = runners + ctx.Data["Total"] = count + ctx.Data["RegistrationToken"] = token.Token + ctx.Data["RunnerOwnerID"] = opts.OwnerID + ctx.Data["RunnerRepoID"] = opts.RepoID + ctx.Data["SortType"] = opts.Sort + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + + ctx.Data["Page"] = pager +} + +// RunnerDetails prepares data for runners edit page +func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int64) { + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + ctx.ServerError("GetRunnerByID", err) + return + } + if err := runner.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + if !runner.Editable(ownerID, repoID) { + err = errors.New("no permission to edit this runner") + ctx.NotFound("RunnerDetails", err) + return + } + + ctx.Data["Runner"] = runner + + opts := actions_model.FindTaskOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: 30, + }, + Status: actions_model.StatusUnknown, // Unknown means all + RunnerID: runner.ID, + } + + tasks, count, err := db.FindAndCount[actions_model.ActionTask](ctx, opts) + if err != nil { + ctx.ServerError("CountTasks", err) + return + } + + if err = actions_model.TaskList(tasks).LoadAttributes(ctx); err != nil { + ctx.ServerError("TasksLoadAttributes", err) + return + } + + ctx.Data["Tasks"] = tasks + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + ctx.Data["Page"] = pager +} + +// RunnerDetailsEditPost response for edit runner details +func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64, redirectTo string) { + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL) + ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err) + return + } + if !runner.Editable(ownerID, repoID) { + ctx.NotFound("RunnerDetailsEditPost.Editable", util.NewPermissionDeniedErrorf("no permission to edit this runner")) + return + } + + form := web.GetForm(ctx).(*forms.EditRunnerForm) + runner.Description = form.Description + + err = actions_model.UpdateRunner(ctx, runner, "description") + if err != nil { + log.Warn("RunnerDetailsEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) + ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner_failed")) + ctx.Redirect(redirectTo) + return + } + + log.Debug("RunnerDetailsEditPost success: %s", ctx.Req.URL) + + ctx.Flash.Success(ctx.Tr("actions.runners.update_runner_success")) + ctx.Redirect(redirectTo) +} + +// RunnerResetRegistrationToken reset registration token +func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, redirectTo string) { + _, err := actions_model.NewRunnerToken(ctx, ownerID, repoID) + if err != nil { + ctx.ServerError("ResetRunnerRegistrationToken", err) + return + } + + ctx.Flash.Success(ctx.Tr("actions.runners.reset_registration_token_success")) + ctx.Redirect(redirectTo) +} + +// RunnerDeletePost response for deleting a runner +func RunnerDeletePost(ctx *context.Context, runnerID int64, + successRedirectTo, failedRedirectTo string, +) { + if err := actions_model.DeleteRunner(ctx, runnerID); err != nil { + log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) + ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed")) + + ctx.JSONRedirect(failedRedirectTo) + return + } + + log.Info("DeleteRunnerPost success: %s", ctx.Req.URL) + + ctx.Flash.Success(ctx.Tr("actions.runners.delete_runner_success")) + + ctx.JSONRedirect(successRedirectTo) +} diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go new file mode 100644 index 0000000..79c03e4 --- /dev/null +++ b/routers/web/shared/actions/variables.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { + variables, err := db.Find[actions_model.ActionVariable](ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + RepoID: repoID, + }) + if err != nil { + ctx.ServerError("FindVariables", err) + return + } + ctx.Data["Variables"] = variables +} + +func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) { + form := web.GetForm(ctx).(*forms.EditVariableForm) + + v, err := actions_service.CreateVariable(ctx, ownerID, repoID, form.Name, form.Data) + if err != nil { + log.Error("CreateVariable: %v", err) + ctx.JSONError(ctx.Tr("actions.variables.creation.failed")) + return + } + + ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name)) + ctx.JSONRedirect(redirectURL) +} + +func UpdateVariable(ctx *context.Context, redirectURL string) { + id := ctx.ParamsInt64(":variable_id") + form := web.GetForm(ctx).(*forms.EditVariableForm) + + if ok, err := actions_service.UpdateVariable(ctx, id, form.Name, form.Data); err != nil || !ok { + log.Error("UpdateVariable: %v", err) + ctx.JSONError(ctx.Tr("actions.variables.update.failed")) + return + } + ctx.Flash.Success(ctx.Tr("actions.variables.update.success")) + ctx.JSONRedirect(redirectURL) +} + +func DeleteVariable(ctx *context.Context, redirectURL string) { + id := ctx.ParamsInt64(":variable_id") + + if err := actions_service.DeleteVariableByID(ctx, id); err != nil { + log.Error("Delete variable [%d] failed: %v", id, err) + ctx.JSONError(ctx.Tr("actions.variables.deletion.failed")) + return + } + ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success")) + ctx.JSONRedirect(redirectURL) +} diff --git a/routers/web/shared/packages/packages.go b/routers/web/shared/packages/packages.go new file mode 100644 index 0000000..af960f1 --- /dev/null +++ b/routers/web/shared/packages/packages.go @@ -0,0 +1,260 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "errors" + "fmt" + "net/http" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + cargo_service "code.gitea.io/gitea/services/packages/cargo" + container_service "code.gitea.io/gitea/services/packages/container" +) + +func SetPackagesContext(ctx *context.Context, owner *user_model.User) { + pcrs, err := packages_model.GetCleanupRulesByOwner(ctx, owner.ID) + if err != nil { + ctx.ServerError("GetCleanupRulesByOwner", err) + return + } + + ctx.Data["CleanupRules"] = pcrs + + ctx.Data["CargoIndexExists"], err = repo_model.IsRepositoryModelExist(ctx, owner, cargo_service.IndexRepositoryName) + if err != nil { + ctx.ServerError("IsRepositoryModelExist", err) + return + } +} + +func SetRuleAddContext(ctx *context.Context) { + setRuleEditContext(ctx, nil) +} + +func SetRuleEditContext(ctx *context.Context, owner *user_model.User) { + pcr := getCleanupRuleByContext(ctx, owner) + if pcr == nil { + return + } + + setRuleEditContext(ctx, pcr) +} + +func setRuleEditContext(ctx *context.Context, pcr *packages_model.PackageCleanupRule) { + ctx.Data["IsEditRule"] = pcr != nil + + if pcr == nil { + pcr = &packages_model.PackageCleanupRule{} + } + ctx.Data["CleanupRule"] = pcr + ctx.Data["AvailableTypes"] = packages_model.TypeList +} + +func PerformRuleAddPost(ctx *context.Context, owner *user_model.User, redirectURL string, template base.TplName) { + performRuleEditPost(ctx, owner, nil, redirectURL, template) +} + +func PerformRuleEditPost(ctx *context.Context, owner *user_model.User, redirectURL string, template base.TplName) { + pcr := getCleanupRuleByContext(ctx, owner) + if pcr == nil { + return + } + + form := web.GetForm(ctx).(*forms.PackageCleanupRuleForm) + + if form.Action == "remove" { + if err := packages_model.DeleteCleanupRuleByID(ctx, pcr.ID); err != nil { + ctx.ServerError("DeleteCleanupRuleByID", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cleanuprules.success.delete")) + ctx.Redirect(redirectURL) + } else { + performRuleEditPost(ctx, owner, pcr, redirectURL, template) + } +} + +func performRuleEditPost(ctx *context.Context, owner *user_model.User, pcr *packages_model.PackageCleanupRule, redirectURL string, template base.TplName) { + isEditRule := pcr != nil + + if pcr == nil { + pcr = &packages_model.PackageCleanupRule{} + } + + form := web.GetForm(ctx).(*forms.PackageCleanupRuleForm) + + pcr.Enabled = form.Enabled + pcr.OwnerID = owner.ID + pcr.KeepCount = form.KeepCount + pcr.KeepPattern = form.KeepPattern + pcr.RemoveDays = form.RemoveDays + pcr.RemovePattern = form.RemovePattern + pcr.MatchFullName = form.MatchFullName + + ctx.Data["IsEditRule"] = isEditRule + ctx.Data["CleanupRule"] = pcr + ctx.Data["AvailableTypes"] = packages_model.TypeList + + if ctx.HasError() { + ctx.HTML(http.StatusOK, template) + return + } + + if isEditRule { + if err := packages_model.UpdateCleanupRule(ctx, pcr); err != nil { + ctx.ServerError("UpdateCleanupRule", err) + return + } + } else { + pcr.Type = packages_model.Type(form.Type) + + if has, err := packages_model.HasOwnerCleanupRuleForPackageType(ctx, owner.ID, pcr.Type); err != nil { + ctx.ServerError("HasOwnerCleanupRuleForPackageType", err) + return + } else if has { + ctx.Data["Err_Type"] = true + ctx.HTML(http.StatusOK, template) + return + } + + var err error + if pcr, err = packages_model.InsertCleanupRule(ctx, pcr); err != nil { + ctx.ServerError("InsertCleanupRule", err) + return + } + } + + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cleanuprules.success.update")) + ctx.Redirect(fmt.Sprintf("%s/rules/%d", redirectURL, pcr.ID)) +} + +func SetRulePreviewContext(ctx *context.Context, owner *user_model.User) { + pcr := getCleanupRuleByContext(ctx, owner) + if pcr == nil { + return + } + + if err := pcr.CompiledPattern(); err != nil { + ctx.ServerError("CompiledPattern", err) + return + } + + olderThan := time.Now().AddDate(0, 0, -pcr.RemoveDays) + + packages, err := packages_model.GetPackagesByType(ctx, pcr.OwnerID, pcr.Type) + if err != nil { + ctx.ServerError("GetPackagesByType", err) + return + } + + versionsToRemove := make([]*packages_model.PackageDescriptor, 0, 10) + + for _, p := range packages { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: p.ID, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + Paginator: db.NewAbsoluteListOptions(pcr.KeepCount, 200), + }) + if err != nil { + ctx.ServerError("SearchVersions", err) + return + } + for _, pv := range pvs { + if skip, err := container_service.ShouldBeSkipped(ctx, pcr, p, pv); err != nil { + ctx.ServerError("ShouldBeSkipped", err) + return + } else if skip { + continue + } + + toMatch := pv.LowerVersion + if pcr.MatchFullName { + toMatch = p.LowerName + "/" + pv.LowerVersion + } + + if pcr.KeepPatternMatcher != nil && pcr.KeepPatternMatcher.MatchString(toMatch) { + continue + } + if pv.CreatedUnix.AsLocalTime().After(olderThan) { + continue + } + if pcr.RemovePatternMatcher != nil && !pcr.RemovePatternMatcher.MatchString(toMatch) { + continue + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + ctx.ServerError("GetPackageDescriptor", err) + return + } + versionsToRemove = append(versionsToRemove, pd) + } + } + + ctx.Data["CleanupRule"] = pcr + ctx.Data["VersionsToRemove"] = versionsToRemove +} + +func getCleanupRuleByContext(ctx *context.Context, owner *user_model.User) *packages_model.PackageCleanupRule { + id := ctx.FormInt64("id") + if id == 0 { + id = ctx.ParamsInt64("id") + } + + pcr, err := packages_model.GetCleanupRuleByID(ctx, id) + if err != nil { + if err == packages_model.ErrPackageCleanupRuleNotExist { + ctx.NotFound("", err) + } else { + ctx.ServerError("GetCleanupRuleByID", err) + } + return nil + } + + if pcr != nil && pcr.OwnerID == owner.ID { + return pcr + } + + ctx.NotFound("", fmt.Errorf("PackageCleanupRule[%v] not associated to owner %v", id, owner)) + + return nil +} + +func InitializeCargoIndex(ctx *context.Context, owner *user_model.User) { + err := cargo_service.InitializeIndexRepository(ctx, owner, owner) + if err != nil { + log.Error("InitializeIndexRepository failed: %v", err) + ctx.Flash.Error(ctx.Tr("packages.owner.settings.cargo.initialize.error", err)) + } else { + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cargo.initialize.success")) + } +} + +func RebuildCargoIndex(ctx *context.Context, owner *user_model.User) { + err := cargo_service.RebuildIndex(ctx, owner, owner) + if err != nil { + log.Error("RebuildIndex failed: %v", err) + if errors.Is(err, util.ErrNotExist) { + ctx.Flash.Error(ctx.Tr("packages.owner.settings.cargo.rebuild.no_index")) + } else { + ctx.Flash.Error(ctx.Tr("packages.owner.settings.cargo.rebuild.error", err)) + } + } else { + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cargo.rebuild.success")) + } +} diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go new file mode 100644 index 0000000..599842e --- /dev/null +++ b/routers/web/shared/project/column.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" +) + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + type movedColumnsForm struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` + } + + form := &movedColumnsForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go new file mode 100644 index 0000000..3bd421f --- /dev/null +++ b/routers/web/shared/secrets/secrets.go @@ -0,0 +1,53 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "code.gitea.io/gitea/models/db" + secret_model "code.gitea.io/gitea/models/secret" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + secret_service "code.gitea.io/gitea/services/secrets" +) + +func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { + secrets, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{OwnerID: ownerID, RepoID: repoID}) + if err != nil { + ctx.ServerError("FindSecrets", err) + return + } + + ctx.Data["Secrets"] = secrets +} + +func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { + form := web.GetForm(ctx).(*forms.AddSecretForm) + + s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data)) + if err != nil { + log.Error("CreateOrUpdateSecret failed: %v", err) + ctx.JSONError(ctx.Tr("secrets.creation.failed")) + return + } + + ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name)) + ctx.JSONRedirect(redirectURL) +} + +func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { + id := ctx.FormInt64("id") + + err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id) + if err != nil { + log.Error("DeleteSecretByID(%d) failed: %v", id, err) + ctx.JSONError(ctx.Tr("secrets.deletion.failed")) + return + } + + ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) + ctx.JSONRedirect(redirectURL) +} diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go new file mode 100644 index 0000000..fd7605c --- /dev/null +++ b/routers/web/shared/user/header.go @@ -0,0 +1,163 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/url" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + packages_model "code.gitea.io/gitea/models/packages" + access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// prepareContextForCommonProfile store some common data into context data for user's profile related pages (including the nav menu) +// It is designed to be fast and safe to be called multiple times in one request +func prepareContextForCommonProfile(ctx *context.Context) { + ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["EnableFeed"] = setting.Other.EnableFeed + ctx.Data["FeedURL"] = ctx.ContextUser.HomeLink() +} + +// PrepareContextForProfileBigAvatar set the context for big avatar view on the profile page +func PrepareContextForProfileBigAvatar(ctx *context.Context) { + prepareContextForCommonProfile(ctx) + + ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate + if setting.Service.UserLocationMapURL != "" { + ctx.Data["ContextUserLocationMapURL"] = setting.Service.UserLocationMapURL + url.QueryEscape(ctx.ContextUser.Location) + } + // Show OpenID URIs + openIDs, err := user_model.GetUserOpenIDs(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openIDs + if len(ctx.ContextUser.Description) != 0 { + content, err := markdown.RenderString(&markup.RenderContext{ + Metas: map[string]string{"mode": "document"}, + Ctx: ctx, + }, ctx.ContextUser.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + ctx.Data["RenderedDescription"] = content + } + + showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{ + UserID: ctx.ContextUser.ID, + IncludePrivate: showPrivate, + }) + if err != nil { + ctx.ServerError("FindOrgs", err) + return + } + ctx.Data["Orgs"] = orgs + ctx.Data["HasOrgsVisible"] = organization.HasOrgsVisible(ctx, orgs, ctx.Doer) + + badges, _, err := user_model.GetUserBadges(ctx, ctx.ContextUser) + if err != nil { + ctx.ServerError("GetUserBadges", err) + return + } + ctx.Data["Badges"] = badges + + // in case the numbers are already provided by other functions, no need to query again (which is slow) + if _, ok := ctx.Data["NumFollowers"]; !ok { + _, ctx.Data["NumFollowers"], _ = user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{PageSize: 1, Page: 1}) + } + if _, ok := ctx.Data["NumFollowing"]; !ok { + _, ctx.Data["NumFollowing"], _ = user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{PageSize: 1, Page: 1}) + } +} + +func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { + profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ".profile") + if err == nil { + perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) + if err == nil && !profileDbRepo.IsEmpty && perm.CanRead(unit.TypeCode) { + if profileGitRepo, err = gitrepo.OpenRepository(ctx, profileDbRepo); err != nil { + log.Error("FindUserProfileReadme failed to OpenRepository: %v", err) + } else { + if commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch); err != nil { + log.Error("FindUserProfileReadme failed to GetBranchCommit: %v", err) + } else { + profileReadmeBlob, _ = commit.GetBlobByFoldedPath("README.md") + } + } + } + } else if !repo_model.IsErrRepoNotExist(err) { + log.Error("FindUserProfileReadme failed to GetRepositoryByName: %v", err) + } + return profileDbRepo, profileGitRepo, profileReadmeBlob, func() { + if profileGitRepo != nil { + _ = profileGitRepo.Close() + } + } +} + +func RenderUserHeader(ctx *context.Context) { + prepareContextForCommonProfile(ctx) + + _, _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx, ctx.Doer) + defer profileClose() + ctx.Data["HasProfileReadme"] = profileReadmeBlob != nil +} + +func LoadHeaderCount(ctx *context.Context) error { + prepareContextForCommonProfile(ctx) + + var err error + + ctx.Data["RepoCount"], err = repo_model.CountRepository(ctx, &repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + OwnerID: ctx.ContextUser.ID, + Private: ctx.IsSigned, + Collaborate: optional.Some(false), + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + return err + } + + var projectType project_model.Type + if ctx.ContextUser.IsOrganization() { + projectType = project_model.TypeOrganization + } else { + projectType = project_model.TypeIndividual + } + ctx.Data["ProjectCount"], err = db.Count[project_model.Project](ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(false), + Type: projectType, + }) + if err != nil { + return err + } + ctx.Data["PackageCount"], err = packages_model.CountOwnerPackages(ctx, ctx.ContextUser.ID) + if err != nil { + return err + } + + return nil +} diff --git a/routers/web/swagger_json.go b/routers/web/swagger_json.go new file mode 100644 index 0000000..fc39b50 --- /dev/null +++ b/routers/web/swagger_json.go @@ -0,0 +1,13 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "code.gitea.io/gitea/services/context" +) + +// SwaggerV1Json render swagger v1 json +func SwaggerV1Json(ctx *context.Context) { + ctx.JSONTemplate("swagger/v1_json") +} diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go new file mode 100644 index 0000000..04f5101 --- /dev/null +++ b/routers/web/user/avatar.go @@ -0,0 +1,57 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "strings" + "time" + + "code.gitea.io/gitea/models/avatars" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/services/context" +) + +func cacheableRedirect(ctx *context.Context, location string) { + // here we should not use `setting.StaticCacheTime`, it is pretty long (default: 6 hours) + // we must make sure the redirection cache time is short enough, otherwise a user won't see the updated avatar in 6 hours + // it's OK to make the cache time short, it is only a redirection, and doesn't cost much to make a new request + httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 5*time.Minute) + ctx.Redirect(location) +} + +// AvatarByUserName redirect browser to user avatar of requested size +func AvatarByUserName(ctx *context.Context) { + userName := ctx.Params(":username") + size := int(ctx.ParamsInt64(":size")) + + var user *user_model.User + if strings.ToLower(userName) != user_model.GhostUserLowerName { + var err error + if user, err = user_model.GetUserByName(ctx, userName); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", err) + return + } + ctx.ServerError("Invalid user: "+userName, err) + return + } + } else { + user = user_model.NewGhostUser() + } + + cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, size)) +} + +// AvatarByEmailHash redirects the browser to the email avatar link +func AvatarByEmailHash(ctx *context.Context) { + hash := ctx.Params(":hash") + email, err := avatars.GetEmailForHash(ctx, hash) + if err != nil { + ctx.ServerError("invalid avatar hash: "+hash, err) + return + } + size := ctx.FormInt("size") + cacheableRedirect(ctx, avatars.GenerateEmailAvatarFinalLink(ctx, email, size)) +} diff --git a/routers/web/user/code.go b/routers/web/user/code.go new file mode 100644 index 0000000..e2e8f25 --- /dev/null +++ b/routers/web/user/code.go @@ -0,0 +1,129 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplUserCode base.TplName = "user/code" +) + +// CodeSearch render user/organization code search page +func CodeSearch(ctx *context.Context) { + if !setting.Indexer.RepoIndexerEnabled { + ctx.Redirect(ctx.ContextUser.HomeLink()) + return + } + shared_user.PrepareContextForProfileBigAvatar(ctx) + shared_user.RenderUserHeader(ctx) + + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["Title"] = ctx.Tr("explore.code") + + language := ctx.FormTrim("l") + keyword := ctx.FormTrim("q") + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["IsCodePage"] = true + + if keyword == "" { + ctx.HTML(http.StatusOK, tplUserCode) + return + } + + var ( + repoIDs []int64 + err error + ) + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + repoIDs, err = repo_model.FindUserCodeAccessibleOwnerRepoIDs(ctx, ctx.ContextUser.ID, ctx.Doer) + if err != nil { + ctx.ServerError("FindUserCodeAccessibleOwnerRepoIDs", err) + return + } + + var ( + total int + searchResults []*code_indexer.Result + searchResultLanguages []*code_indexer.SearchResultLanguages + ) + + if len(repoIDs) > 0 { + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: repoIDs, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["CodeIndexerUnavailable"] = true + } else { + ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + } + + loadRepoIDs := make([]int64, 0, len(searchResults)) + for _, result := range searchResults { + var find bool + for _, id := range loadRepoIDs { + if id == result.RepoID { + find = true + break + } + } + if !find { + loadRepoIDs = append(loadRepoIDs, result.RepoID) + } + } + + repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + + ctx.Data["RepoMaps"] = repoMaps + } + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + + 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, tplUserCode) +} diff --git a/routers/web/user/home.go b/routers/web/user/home.go new file mode 100644 index 0000000..4b249e9 --- /dev/null +++ b/routers/web/user/home.go @@ -0,0 +1,883 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "bytes" + "fmt" + "net/http" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + 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/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/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) *user_model.User { + ctxUser := ctx.Doer + orgName := ctx.Params(":org") + if len(orgName) > 0 { + ctxUser = ctx.Org.Organization.AsUser() + ctx.Data["Teams"] = ctx.Org.Teams + } + ctx.Data["ContextUser"] = ctxUser + + orgs, err := organization.GetUserOrgsList(ctx, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserOrgsList", err) + return nil + } + ctx.Data["Orgs"] = orgs + + return ctxUser +} + +// Dashboard render the dashboard page +func Dashboard(ctx *context.Context) { + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + var ( + date = ctx.FormString("date") + page = ctx.FormInt("page") + ) + + // Make sure page number is at least 1. Will be posted to ctx.Data. + if page <= 1 { + page = 1 + } + + ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Locale.TrString("dashboard") + ctx.Data["PageIsDashboard"] = true + ctx.Data["PageIsNews"] = true + cnt, _ := organization.GetOrganizationCount(ctx, ctxUser) + ctx.Data["UserOrgsCount"] = cnt + ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled + ctx.Data["Date"] = date + + var uid int64 + if ctxUser != nil { + uid = ctxUser.ID + } + + ctx.PageData["dashboardRepoList"] = map[string]any{ + "searchLimit": setting.UI.User.RepoPagingNum, + "uid": uid, + } + + if setting.Service.EnableUserHeatmap { + data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUserTeam", err) + return + } + ctx.Data["HeatmapData"] = data + ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) + } + + feeds, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + RequestedUser: ctxUser, + RequestedTeam: ctx.Org.Team, + Actor: ctx.Doer, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: false, + Date: ctx.FormString("date"), + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.FeedPagingNum, + }, + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + ctx.Data["Feeds"] = feeds + + pager := context.NewPagination(int(count), setting.UI.FeedPagingNum, page, 5) + pager.AddParam(ctx, "date", "Date") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplDashboard) +} + +// Milestones render the user milestones page +func Milestones(ctx *context.Context) { + if unit.TypeIssues.UnitGlobalDisabled() && unit.TypePullRequests.UnitGlobalDisabled() { + log.Debug("Milestones overview page not available as both issues and pull requests are globally disabled") + ctx.Status(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("milestones") + ctx.Data["PageIsMilestonesDashboard"] = true + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + repoOpts := repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + 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 + Archived: optional.Some(false), + HasMilestones: optional.Some(true), // Just needs display repos has milestones + } + + if ctxUser.IsOrganization() && ctx.Org.Team != nil { + repoOpts.TeamID = ctx.Org.Team.ID + } + + var ( + userRepoCond = repo_model.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit + repoCond = userRepoCond + repoIDs []int64 + + reposQuery = ctx.FormString("repos") + isShowClosed = ctx.FormString("state") == "closed" + sortType = ctx.FormString("sort") + page = ctx.FormInt("page") + keyword = ctx.FormTrim("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 := issues_model.CountMilestonesMap(ctx, issues_model.FindMilestoneOptions{ + RepoCond: userRepoCond, + Name: keyword, + IsClosed: optional.Some(isShowClosed), + }) + if err != nil { + ctx.ServerError("CountMilestonesByRepoIDs", err) + return + } + + milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + RepoCond: repoCond, + IsClosed: optional.Some(isShowClosed), + SortType: sortType, + Name: keyword, + }) + if err != nil { + ctx.ServerError("SearchMilestones", err) + return + } + + showRepos, _, err := repo_model.SearchRepositoryByCondition(ctx, &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{ + Links: markup.Links{ + Base: milestones[i].Repo.Link(), + }, + Metas: milestones[i].Repo.ComposeMetas(ctx), + Ctx: ctx, + }, milestones[i].Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + if milestones[i].Repo.IsTimetrackerEnabled(ctx) { + err := milestones[i].LoadTotalTrackedTime(ctx) + if err != nil { + ctx.ServerError("LoadTotalTrackedTime", err) + return + } + } + i++ + } + + milestoneStats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, repoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + + var totalMilestoneStats *issues_model.MilestonesStats + if len(repoIDs) == 0 { + totalMilestoneStats = milestoneStats + } else { + totalMilestoneStats, err = issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, userRepoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + } + + showRepoIDs := make(container.Set[int64], len(showRepos)) + for _, repo := range showRepos { + if repo.ID > 0 { + showRepoIDs.Add(repo.ID) + } + } + if len(repoIDs) == 0 { + repoIDs = showRepoIDs.Values() + } + repoIDs = slices.DeleteFunc(repoIDs, func(v int64) bool { + return !showRepoIDs.Contains(v) + }) + + 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 + 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 unit.TypePullRequests.UnitGlobalDisabled() { + log.Debug("Pull request overview page not available as it is globally disabled.") + ctx.Status(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("pull_requests") + ctx.Data["PageIsPulls"] = true + buildIssueOverview(ctx, unit.TypePullRequests) +} + +// Issues renders the user's issues overview page +func Issues(ctx *context.Context) { + if unit.TypeIssues.UnitGlobalDisabled() { + log.Debug("Issues overview page not available as it is globally disabled.") + ctx.Status(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("issues") + ctx.Data["PageIsIssues"] = true + buildIssueOverview(ctx, unit.TypeIssues) +} + +// Regexp for repos query +var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`) + +func buildIssueOverview(ctx *context.Context, unitType unit.Type) { + // ---------------------------------------------------- + // 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.FormString("sort") + filterMode int + ) + + // Default to recently updated, unlike repository issues list + if sortType == "" { + sortType = "recentupdate" + } + + // -------------------------------------------------------------------------------- + // 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.FormString("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.FormString("type") + switch viewType { + case "assigned": + filterMode = issues_model.FilterModeAssign + case "mentioned": + filterMode = issues_model.FilterModeMention + case "review_requested": + filterMode = issues_model.FilterModeReviewRequested + case "reviewed_by": + filterMode = issues_model.FilterModeReviewed + case "your_repositories": + filterMode = issues_model.FilterModeYourRepositories + case "created_by": + fallthrough + default: + filterMode = issues_model.FilterModeCreate + viewType = "created_by" + } + + // -------------------------------------------------------------------------- + // 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 + // -------------------------------------------------------------------------- + + // Get repository IDs where User/Org/Team has access. + var team *organization.Team + var org *organization.Organization + if ctx.Org != nil { + org = ctx.Org.Organization + team = ctx.Org.Team + } + + isPullList := unitType == unit.TypePullRequests + opts := &issues_model.IssuesOptions{ + IsPull: optional.Some(isPullList), + SortType: sortType, + IsArchived: optional.Some(false), + Org: org, + Team: team, + User: ctx.Doer, + } + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + // Search all repositories which + // + // As user: + // - Owns the repository. + // - Have collaborator permissions in repository. + // + // As org: + // - Owns the repository. + // + // As team: + // - Team org's owns the repository. + // - Team has read permission to repository. + repoOpts := &repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + OwnerID: ctxUser.ID, + Private: true, + AllPublic: false, + AllLimited: false, + Collaborate: optional.None[bool](), + UnitType: unitType, + Archived: optional.Some(false), + } + if team != nil { + repoOpts.TeamID = team.ID + } + accessibleRepos := container.Set[int64]{} + { + ids, _, err := repo_model.SearchRepositoryIDs(ctx, repoOpts) + if err != nil { + ctx.ServerError("SearchRepositoryIDs", err) + return + } + accessibleRepos.AddMultiple(ids...) + opts.RepoIDs = ids + if len(opts.RepoIDs) == 0 { + // no repos found, don't let the indexer return all repos + opts.RepoIDs = []int64{0} + } + } + if ctx.Doer.ID == ctxUser.ID && filterMode != issues_model.FilterModeYourRepositories { + // If the doer is the same as the context user, which means the doer is viewing his own dashboard, + // it's not enough to show the repos that the doer owns or has been explicitly granted access to, + // because the doer may create issues or be mentioned in any public repo. + // So we need search issues in all public repos. + opts.AllPublic = true + } + + switch filterMode { + case issues_model.FilterModeAll: + case issues_model.FilterModeYourRepositories: + case issues_model.FilterModeAssign: + opts.AssigneeID = ctx.Doer.ID + case issues_model.FilterModeCreate: + opts.PosterID = ctx.Doer.ID + case issues_model.FilterModeMention: + opts.MentionedID = ctx.Doer.ID + case issues_model.FilterModeReviewRequested: + opts.ReviewRequestedID = ctx.Doer.ID + case issues_model.FilterModeReviewed: + opts.ReviewedID = ctx.Doer.ID + } + + // keyword holds the search term entered into the search field. + keyword := strings.Trim(ctx.FormString("q"), " ") + ctx.Data["Keyword"] = keyword + + // Educated guess: Do or don't show closed issues. + isShowClosed := ctx.FormString("state") == "closed" + opts.IsClosed = optional.Some(isShowClosed) + + // Make sure page number is at least 1. Will be posted to ctx.Data. + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + opts.Paginator = &db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + } + + // Get IDs for labels (a filter option for issues/pulls). + // Required for IssuesOptions. + selectedLabels := ctx.FormString("labels") + if len(selectedLabels) > 0 && selectedLabels != "0" { + var err error + opts.LabelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true) + } + } + + if org != nil { + // Get Org Labels + labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + // Get the exclusive scope for every label ID + labelExclusiveScopes := make([]string, 0, len(opts.LabelIDs)) + for _, labelID := range opts.LabelIDs { + foundExclusiveScope := false + for _, label := range labels { + if label.ID == labelID || label.ID == -labelID { + labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope()) + foundExclusiveScope = true + break + } + } + if !foundExclusiveScope { + labelExclusiveScopes = append(labelExclusiveScopes, "") + } + } + + for _, l := range labels { + l.LoadSelectedLabelsAfterClick(opts.LabelIDs, labelExclusiveScopes) + } + ctx.Data["Labels"] = labels + } + + // ------------------------------ + // 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 issues_model.IssueList + { + issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy( + func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy }, + )) + if err != nil { + ctx.ServerError("issueIDsFromSearch", err) + return + } + issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return + } + } + + commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesLastCommitStatus", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + + // ------------------------------- + // Fill stats to post to ctx.Data. + // ------------------------------- + issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy( + func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy }, + )) + if err != nil { + ctx.ServerError("getUserIssueStats", err) + return + } + + // Will be posted to ctx.Data. + var shownIssues int + if !isShowClosed { + shownIssues = int(issueStats.OpenCount) + } else { + shownIssues = int(issueStats.ClosedCount) + } + + ctx.Data["IsShowClosed"] = isShowClosed + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.FormString("RepoLink")) + + if err := issues.LoadAttributes(ctx); err != nil { + ctx.ServerError("issues.LoadAttributes", err) + return + } + ctx.Data["Issues"] = issues + + approvalCounts, err := issues.GetApprovalCounts(ctx) + 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 := issues_model.ReviewTypeApprove + if typ == "reject" { + reviewTyp = issues_model.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = issues_model.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + ctx.Data["CommitLastStatus"] = lastStatus + ctx.Data["CommitStatuses"] = commitStatuses + ctx.Data["IssueStats"] = issueStats + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["SelectLabels"] = selectedLabels + ctx.Data["PageIsOrgIssues"] = org != nil + ctx.Data["IsFuzzy"] = isFuzzy + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5) + 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") + pager.AddParam(ctx, "fuzzy", "IsFuzzy") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplIssues) +} + +// ShowSSHKeys output all the ssh keys of user by uid +func ShowSSHKeys(ctx *context.Context) { + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: ctx.ContextUser.ID, + }) + 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.PlainTextBytes(http.StatusOK, buf.Bytes()) +} + +// ShowGPGKeys output all the public GPG keys of user by uid +func ShowGPGKeys(ctx *context.Context) { + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.ContextUser.ID, + }) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + + entities := make([]*openpgp.Entity, 0) + failedEntitiesID := make([]string, 0) + for _, k := range keys { + e, err := asymkey_model.GPGKeyToEntity(ctx, k) + if err != nil { + if asymkey_model.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, ", ")) + } else if len(entities) == 0 { + headers["Note"] = "This user hasn't uploaded any GPG keys." + } + 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.PlainTextBytes(http.StatusOK, buf.Bytes()) +} + +func UsernameSubRoute(ctx *context.Context) { + // WORKAROUND to support usernames with "." in it + // https://github.com/go-chi/chi/issues/781 + username := ctx.Params("username") + reloadParam := func(suffix string) (success bool) { + ctx.SetParams("username", strings.TrimSuffix(username, suffix)) + context.UserAssignmentWeb()(ctx) + if ctx.Written() { + return false + } + // check view permissions + if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.NotFound("User not visible", nil) + return false + } + return true + } + switch { + case strings.HasSuffix(username, ".png"): + if reloadParam(".png") { + AvatarByUserName(ctx) + } + case strings.HasSuffix(username, ".keys"): + if reloadParam(".keys") { + ShowSSHKeys(ctx) + } + case strings.HasSuffix(username, ".gpg"): + if reloadParam(".gpg") { + ShowGPGKeys(ctx) + } + case strings.HasSuffix(username, ".rss"): + if !setting.Other.EnableFeed { + ctx.Error(http.StatusNotFound) + return + } + if reloadParam(".rss") { + feed.ShowUserFeedRSS(ctx) + } + case strings.HasSuffix(username, ".atom"): + if !setting.Other.EnableFeed { + ctx.Error(http.StatusNotFound) + return + } + if reloadParam(".atom") { + feed.ShowUserFeedAtom(ctx) + } + default: + context.UserAssignmentWeb()(ctx) + if !ctx.Written() { + ctx.Data["EnableFeed"] = setting.Other.EnableFeed + OwnerProfile(ctx) + } + } +} + +func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (*issues_model.IssueStats, error) { + doerID := ctx.Doer.ID + + opts = opts.Copy(func(o *issue_indexer.SearchOptions) { + // If the doer is the same as the context user, which means the doer is viewing his own dashboard, + // it's not enough to show the repos that the doer owns or has been explicitly granted access to, + // because the doer may create issues or be mentioned in any public repo. + // So we need search issues in all public repos. + o.AllPublic = doerID == ctxUser.ID + o.AssigneeID = nil + o.PosterID = nil + o.MentionID = nil + o.ReviewRequestedID = nil + o.ReviewedID = nil + }) + + var ( + err error + ret = &issues_model.IssueStats{} + ) + + { + openClosedOpts := opts.Copy() + switch filterMode { + case issues_model.FilterModeAll: + // no-op + case issues_model.FilterModeYourRepositories: + openClosedOpts.AllPublic = false + case issues_model.FilterModeAssign: + openClosedOpts.AssigneeID = optional.Some(doerID) + case issues_model.FilterModeCreate: + openClosedOpts.PosterID = optional.Some(doerID) + case issues_model.FilterModeMention: + openClosedOpts.MentionID = optional.Some(doerID) + case issues_model.FilterModeReviewRequested: + openClosedOpts.ReviewRequestedID = optional.Some(doerID) + case issues_model.FilterModeReviewed: + openClosedOpts.ReviewedID = optional.Some(doerID) + } + openClosedOpts.IsClosed = optional.Some(false) + ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts) + if err != nil { + return nil, err + } + openClosedOpts.IsClosed = optional.Some(true) + ret.ClosedCount, err = issue_indexer.CountIssues(ctx, openClosedOpts) + if err != nil { + return nil, err + } + } + + ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AllPublic = false })) + if err != nil { + return nil, err + } + ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + return ret, nil +} diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go new file mode 100644 index 0000000..e1c8ca9 --- /dev/null +++ b/routers/web/user/home_test.go @@ -0,0 +1,169 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArchivedIssues(t *testing.T) { + // Arrange + setting.UI.IssuePagingNum = 1 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "issues") + contexttest.LoadUser(t, ctx, 30) + ctx.Req.Form.Set("state", "open") + ctx.Req.Form.Set("type", "your_repositories") + + // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived. + repos, _, _ := repo_model.GetUserRepositories(db.DefaultContext, &repo_model.SearchRepoOptions{Actor: ctx.Doer}) + assert.Len(t, repos, 3) + 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.Len(t, ctx.Data["Issues"], 1) +} + +func TestIssues(t *testing.T) { + setting.UI.IssuePagingNum = 1 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "issues") + contexttest.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "closed") + Issues(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.Len(t, ctx.Data["Issues"], 1) +} + +func TestPulls(t *testing.T) { + setting.UI.IssuePagingNum = 20 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "pulls") + contexttest.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "open") + ctx.Req.Form.Set("type", "your_repositories") + Pulls(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.Len(t, ctx.Data["Issues"], 5) +} + +func TestMilestones(t *testing.T) { + setting.UI.IssuePagingNum = 1 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "milestones") + contexttest.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 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "milestones") + contexttest.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 +} + +func TestDashboardPagination(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + page := context.NewPagination(10, 3, 1, 3) + + setting.AppSubURL = "/SubPath" + out, err := ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page}) + require.NoError(t, err) + assert.Contains(t, out, `<a class=" item navigation" href="/SubPath/?page=2">`) + + setting.AppSubURL = "" + out, err = ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page}) + require.NoError(t, err) + assert.Contains(t, out, `<a class=" item navigation" href="/?page=2">`) +} + +func TestOrgLabels(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "org/org3/issues") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadOrganization(t, ctx, 3) + Issues(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.True(t, ctx.Data["PageIsOrgIssues"].(bool)) + + orgLabels := []struct { + ID int64 + OrgID int64 + Name string + }{ + {3, 3, "orglabel3"}, + {4, 3, "orglabel4"}, + } + + labels, ok := ctx.Data["Labels"].([]*issues_model.Label) + + assert.True(t, ok) + + if assert.Len(t, labels, len(orgLabels)) { + for i, label := range labels { + assert.EqualValues(t, orgLabels[i].OrgID, label.OrgID) + assert.EqualValues(t, orgLabels[i].ID, label.ID) + assert.EqualValues(t, orgLabels[i].Name, label.Name) + } + } +} diff --git a/routers/web/user/main_test.go b/routers/web/user/main_test.go new file mode 100644 index 0000000..8b6ae69 --- /dev/null +++ b/routers/web/user/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go new file mode 100644 index 0000000..dfcaf58 --- /dev/null +++ b/routers/web/user/notification.go @@ -0,0 +1,485 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + goctx "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" +) + +const ( + tplNotification base.TplName = "user/notification/notification" + tplNotificationDiv base.TplName = "user/notification/notification_div" + tplNotificationSubscriptions base.TplName = "user/notification/notification_subscriptions" +) + +// GetNotificationCount is the middleware that sets the notification count in the context +func GetNotificationCount(ctx *context.Context) { + if strings.HasPrefix(ctx.Req.URL.Path, "/api") { + return + } + + if !ctx.IsSigned { + return + } + + ctx.Data["NotificationUnreadCount"] = func() int64 { + count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, + }) + if err != nil { + if err != goctx.Canceled { + log.Error("Unable to GetNotificationCount for user:%-v: %v", ctx.Doer, err) + } + return -1 + } + + return count + } +} + +// Notifications is the notifications page +func Notifications(ctx *context.Context) { + getNotifications(ctx) + if ctx.Written() { + return + } + if ctx.FormBool("div-only") { + ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number") + ctx.HTML(http.StatusOK, tplNotificationDiv) + return + } + ctx.HTML(http.StatusOK, tplNotification) +} + +func getNotifications(ctx *context.Context) { + var ( + keyword = ctx.FormTrim("q") + status activities_model.NotificationStatus + page = ctx.FormInt("page") + perPage = ctx.FormInt("perPage") + ) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + + switch keyword { + case "read": + status = activities_model.NotificationStatusRead + default: + status = activities_model.NotificationStatusUnread + } + + total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{status}, + }) + if err != nil { + ctx.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 { + ctx.Redirect(fmt.Sprintf("%s/notifications?q=%s&page=%d", setting.AppSubURL, url.QueryEscape(ctx.FormString("q")), pager.Paginater.Current())) + return + } + + statuses := []activities_model.NotificationStatus{status, activities_model.NotificationStatusPinned} + nls, err := db.Find[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + ListOptions: db.ListOptions{ + PageSize: perPage, + Page: page, + }, + UserID: ctx.Doer.ID, + Status: statuses, + }) + if err != nil { + ctx.ServerError("db.Find[activities_model.Notification]", err) + return + } + + notifications := activities_model.NotificationList(nls) + + failCount := 0 + + repos, failures, err := notifications.LoadRepos(ctx) + if err != nil { + ctx.ServerError("LoadRepos", err) + return + } + notifications = notifications.Without(failures) + if err := repos.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + failCount += len(failures) + + failures, err = notifications.LoadIssues(ctx) + if err != nil { + ctx.ServerError("LoadIssues", err) + return + } + + if err = notifications.LoadIssuePullRequests(ctx); err != nil { + ctx.ServerError("LoadIssuePullRequests", err) + return + } + + notifications = notifications.Without(failures) + failCount += len(failures) + + failures, err = notifications.LoadComments(ctx) + if err != nil { + ctx.ServerError("LoadComments", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + + if failCount > 0 { + ctx.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount)) + } + + ctx.Data["Title"] = ctx.Tr("notifications") + ctx.Data["Keyword"] = keyword + ctx.Data["Status"] = status + ctx.Data["Notifications"] = notifications + + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager +} + +// NotificationStatusPost is a route for changing the status of a notification +func NotificationStatusPost(ctx *context.Context) { + var ( + notificationID = ctx.FormInt64("notification_id") + statusStr = ctx.FormString("status") + status activities_model.NotificationStatus + ) + + switch statusStr { + case "read": + status = activities_model.NotificationStatusRead + case "unread": + status = activities_model.NotificationStatusUnread + case "pinned": + status = activities_model.NotificationStatusPinned + default: + ctx.ServerError("InvalidNotificationStatus", errors.New("Invalid notification status")) + return + } + + if _, err := activities_model.SetNotificationStatus(ctx, notificationID, ctx.Doer, status); err != nil { + ctx.ServerError("SetNotificationStatus", err) + return + } + + if !ctx.FormBool("noredirect") { + url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, url.QueryEscape(ctx.FormString("page"))) + ctx.Redirect(url, http.StatusSeeOther) + } + + getNotifications(ctx) + if ctx.Written() { + return + } + ctx.Data["Link"] = setting.AppSubURL + "/notifications" + ctx.Data["SequenceNumber"] = ctx.Req.PostFormValue("sequence-number") + + ctx.HTML(http.StatusOK, tplNotificationDiv) +} + +// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read +func NotificationPurgePost(ctx *context.Context) { + err := activities_model.UpdateNotificationStatuses(ctx, ctx.Doer, activities_model.NotificationStatusUnread, activities_model.NotificationStatusRead) + if err != nil { + ctx.ServerError("UpdateNotificationStatuses", err) + return + } + + ctx.Redirect(setting.AppSubURL+"/notifications", http.StatusSeeOther) +} + +// NotificationSubscriptions returns the list of subscribed issues +func NotificationSubscriptions(ctx *context.Context) { + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + sortType := ctx.FormString("sort") + ctx.Data["SortType"] = sortType + + state := ctx.FormString("state") + if !util.SliceContainsString([]string{"all", "open", "closed"}, state, true) { + state = "all" + } + + ctx.Data["State"] = state + // default state filter is "all" + showClosed := optional.None[bool]() + switch state { + case "closed": + showClosed = optional.Some(true) + case "open": + showClosed = optional.Some(false) + } + + issueType := ctx.FormString("issueType") + // default issue type is no filter + issueTypeBool := optional.None[bool]() + switch issueType { + case "issues": + issueTypeBool = optional.Some(false) + case "pulls": + issueTypeBool = optional.Some(true) + } + ctx.Data["IssueType"] = issueType + + var labelIDs []int64 + selectedLabels := ctx.FormString("labels") + ctx.Data["Labels"] = selectedLabels + if len(selectedLabels) > 0 && selectedLabels != "0" { + var err error + labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true) + } + } + + count, err := issues_model.CountIssues(ctx, &issues_model.IssuesOptions{ + SubscriberID: ctx.Doer.ID, + IsClosed: showClosed, + IsPull: issueTypeBool, + LabelIDs: labelIDs, + }) + if err != nil { + ctx.ServerError("CountIssues", err) + return + } + issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + Paginator: &db.ListOptions{ + PageSize: setting.UI.IssuePagingNum, + Page: page, + }, + SubscriberID: ctx.Doer.ID, + SortType: sortType, + IsClosed: showClosed, + IsPull: issueTypeBool, + LabelIDs: labelIDs, + }) + if err != nil { + ctx.ServerError("Issues", err) + return + } + + commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesAllCommitStatus", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + ctx.Data["CommitLastStatus"] = lastStatus + ctx.Data["CommitStatuses"] = commitStatuses + ctx.Data["Issues"] = issues + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, "") + + commitStatus, err := pull_service.GetIssuesLastCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesLastCommitStatus", err) + return + } + ctx.Data["CommitStatus"] = commitStatus + + approvalCounts, err := issues.GetApprovalCounts(ctx) + 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 := issues_model.ReviewTypeApprove + if typ == "reject" { + reviewTyp = issues_model.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = issues_model.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + + ctx.Data["Status"] = 1 + ctx.Data["Title"] = ctx.Tr("notification.subscriptions") + + // redirect to last page if request page is more than total pages + pager := context.NewPagination(int(count), setting.UI.IssuePagingNum, page, 5) + if pager.Paginater.Current() < page { + ctx.Redirect(fmt.Sprintf("/notifications/subscriptions?page=%d", pager.Paginater.Current())) + return + } + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplNotificationSubscriptions) +} + +// NotificationWatching returns the list of watching repos +func NotificationWatching(ctx *context.Context) { + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + var orderBy db.SearchOrderBy + ctx.Data["SortType"] = ctx.FormString("sort") + switch ctx.FormString("sort") { + case "newest": + orderBy = db.SearchOrderByNewest + case "oldest": + orderBy = db.SearchOrderByOldest + case "recentupdate": + orderBy = db.SearchOrderByRecentUpdated + case "leastupdate": + orderBy = db.SearchOrderByLeastUpdated + case "reversealphabetically": + orderBy = db.SearchOrderByAlphabeticallyReverse + case "alphabetically": + orderBy = db.SearchOrderByAlphabetically + case "moststars": + orderBy = db.SearchOrderByStarsReverse + case "feweststars": + orderBy = db.SearchOrderByStars + case "mostforks": + orderBy = db.SearchOrderByForksReverse + case "fewestforks": + orderBy = db.SearchOrderByForks + default: + ctx.Data["SortType"] = "recentupdate" + orderBy = db.SearchOrderByRecentUpdated + } + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + WatchedByID: ctx.Doer.ID, + Collaborate: optional.Some(false), + TopicOnly: ctx.FormBool("topic"), + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + total := int(count) + ctx.Data["Total"] = total + ctx.Data["Repos"] = repos + + // redirect to last page if request page is more than total pages + pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5) + pager.SetDefaultParams(ctx) + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + ctx.Data["Page"] = pager + + ctx.Data["Status"] = 2 + ctx.Data["Title"] = ctx.Tr("notification.watching") + + ctx.HTML(http.StatusOK, tplNotificationSubscriptions) +} + +// NewAvailable returns the notification counts +func NewAvailable(ctx *context.Context) { + total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, + }) + if err != nil { + log.Error("db.Count[activities_model.Notification]", err) + ctx.JSON(http.StatusOK, structs.NotificationCount{New: 0}) + return + } + + ctx.JSON(http.StatusOK, structs.NotificationCount{New: total}) +} diff --git a/routers/web/user/package.go b/routers/web/user/package.go new file mode 100644 index 0000000..d47a36e --- /dev/null +++ b/routers/web/user/package.go @@ -0,0 +1,513 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + packages_model "code.gitea.io/gitea/models/packages" + container_model "code.gitea.io/gitea/models/packages/container" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + alpine_module "code.gitea.io/gitea/modules/packages/alpine" + arch_model "code.gitea.io/gitea/modules/packages/arch" + debian_module "code.gitea.io/gitea/modules/packages/debian" + rpm_module "code.gitea.io/gitea/modules/packages/rpm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + packages_helper "code.gitea.io/gitea/routers/api/packages/helper" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + packages_service "code.gitea.io/gitea/services/packages" +) + +const ( + tplPackagesList base.TplName = "user/overview/packages" + tplPackagesView base.TplName = "package/view" + tplPackageVersionList base.TplName = "user/overview/package_versions" + tplPackagesSettings base.TplName = "package/settings" +) + +// ListPackages displays a list of all packages of the context user +func ListPackages(ctx *context.Context) { + shared_user.PrepareContextForProfileBigAvatar(ctx) + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + query := ctx.FormTrim("q") + packageType := ctx.FormTrim("type") + + pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + Paginator: &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + }, + OwnerID: ctx.ContextUser.ID, + Type: packages_model.Type(packageType), + Name: packages_model.SearchValue{Value: query}, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchLatestVersions", err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + repositoryAccessMap := make(map[int64]bool) + for _, pd := range pds { + if pd.Repository == nil { + continue + } + if _, has := repositoryAccessMap[pd.Repository.ID]; has { + continue + } + + permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + repositoryAccessMap[pd.Repository.ID] = permission.HasAccess() + } + + hasPackages, err := packages_model.HasOwnerPackages(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("HasOwnerPackages", err) + return + } + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["IsPackagesPage"] = true + ctx.Data["Query"] = query + ctx.Data["PackageType"] = packageType + ctx.Data["AvailableTypes"] = packages_model.TypeList + ctx.Data["HasPackages"] = hasPackages + ctx.Data["PackageDescriptors"] = pds + ctx.Data["Total"] = total + ctx.Data["RepositoryAccessMap"] = repositoryAccessMap + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + // TODO: context/org -> HandleOrgAssignment() can not be used + if ctx.ContextUser.IsOrganization() { + org := org_model.OrgFromUser(ctx.ContextUser) + ctx.Data["Org"] = org + ctx.Data["OrgLink"] = ctx.ContextUser.OrganisationLink() + + if ctx.Doer != nil { + ctx.Data["IsOrganizationMember"], _ = org_model.IsOrganizationMember(ctx, org.ID, ctx.Doer.ID) + ctx.Data["IsOrganizationOwner"], _ = org_model.IsOrganizationOwner(ctx, org.ID, ctx.Doer.ID) + } else { + ctx.Data["IsOrganizationMember"] = false + ctx.Data["IsOrganizationOwner"] = false + } + } + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + pager.AddParam(ctx, "q", "Query") + pager.AddParam(ctx, "type", "PackageType") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackagesList) +} + +// RedirectToLastVersion redirects to the latest package version +func RedirectToLastVersion(ctx *context.Context) { + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.Params("type")), ctx.Params("name")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + ctx.NotFound("GetPackageByName", err) + } else { + ctx.ServerError("GetPackageByName", err) + } + return + } + + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: p.ID, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("GetPackageByName", err) + return + } + if len(pvs) == 0 { + ctx.NotFound("", err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pvs[0]) + if err != nil { + ctx.ServerError("GetPackageDescriptor", err) + return + } + + ctx.Redirect(pd.VersionWebLink()) +} + +// ViewPackageVersion displays a single package version +func ViewPackageVersion(ctx *context.Context) { + pd := ctx.Package.Descriptor + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = pd.Package.Name + ctx.Data["IsPackagesPage"] = true + ctx.Data["PackageDescriptor"] = pd + + switch pd.Package.Type { + case packages_model.TypeContainer: + ctx.Data["RegistryHost"] = setting.Packages.RegistryHost + case packages_model.TypeAlpine: + branches := make(container.Set[string]) + repositories := make(container.Set[string]) + architectures := make(container.Set[string]) + + for _, f := range pd.Files { + for _, pp := range f.Properties { + switch pp.Name { + case alpine_module.PropertyBranch: + branches.Add(pp.Value) + case alpine_module.PropertyRepository: + repositories.Add(pp.Value) + case alpine_module.PropertyArchitecture: + architectures.Add(pp.Value) + } + } + } + + ctx.Data["Branches"] = util.Sorted(branches.Values()) + ctx.Data["Repositories"] = util.Sorted(repositories.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) + case packages_model.TypeArch: + ctx.Data["RegistryHost"] = setting.Packages.RegistryHost + ctx.Data["SignMail"] = fmt.Sprintf("%s@noreply.%s", ctx.Package.Owner.Name, setting.Packages.RegistryHost) + groups := make(container.Set[string]) + for _, f := range pd.Files { + for _, pp := range f.Properties { + if pp.Name == arch_model.PropertyDistribution { + groups.Add(pp.Value) + } + } + } + ctx.Data["Groups"] = util.Sorted(groups.Values()) + case packages_model.TypeDebian: + distributions := make(container.Set[string]) + components := make(container.Set[string]) + architectures := make(container.Set[string]) + + for _, f := range pd.Files { + for _, pp := range f.Properties { + switch pp.Name { + case debian_module.PropertyDistribution: + distributions.Add(pp.Value) + case debian_module.PropertyComponent: + components.Add(pp.Value) + case debian_module.PropertyArchitecture: + architectures.Add(pp.Value) + } + } + } + + ctx.Data["Distributions"] = util.Sorted(distributions.Values()) + ctx.Data["Components"] = util.Sorted(components.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) + case packages_model.TypeRpm: + groups := make(container.Set[string]) + architectures := make(container.Set[string]) + + for _, f := range pd.Files { + for _, pp := range f.Properties { + switch pp.Name { + case rpm_module.PropertyGroup: + groups.Add(pp.Value) + case rpm_module.PropertyArchitecture: + architectures.Add(pp.Value) + } + } + } + + ctx.Data["Groups"] = util.Sorted(groups.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) + } + + var ( + total int64 + pvs []*packages_model.PackageVersion + err error + ) + switch pd.Package.Type { + case packages_model.TypeContainer: + pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{ + Paginator: db.NewAbsoluteListOptions(0, 5), + PackageID: pd.Package.ID, + IsTagged: true, + }) + default: + pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + Paginator: db.NewAbsoluteListOptions(0, 5), + PackageID: pd.Package.ID, + IsInternal: optional.Some(false), + }) + } + if err != nil { + ctx.ServerError("", err) + return + } + + ctx.Data["LatestVersions"] = pvs + ctx.Data["TotalVersionCount"] = total + + ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() + + hasRepositoryAccess := false + if pd.Repository != nil { + permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + hasRepositoryAccess = permission.HasAccess() + } + ctx.Data["HasRepositoryAccess"] = hasRepositoryAccess + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplPackagesView) +} + +// ListPackageVersions lists all versions of a package +func ListPackageVersions(ctx *context.Context) { + shared_user.PrepareContextForProfileBigAvatar(ctx) + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.Params("type")), ctx.Params("name")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + ctx.NotFound("GetPackageByName", err) + } else { + ctx.ServerError("GetPackageByName", err) + } + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + pagination := &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + } + + query := ctx.FormTrim("q") + sort := ctx.FormTrim("sort") + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["IsPackagesPage"] = true + ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{ + Package: p, + Owner: ctx.Package.Owner, + } + ctx.Data["Query"] = query + ctx.Data["Sort"] = sort + + pagerParams := map[string]string{ + "q": query, + "sort": sort, + } + + var ( + total int64 + pvs []*packages_model.PackageVersion + ) + switch p.Type { + case packages_model.TypeContainer: + tagged := ctx.FormTrim("tagged") + + pagerParams["tagged"] = tagged + ctx.Data["Tagged"] = tagged + + pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{ + Paginator: pagination, + PackageID: p.ID, + Query: query, + IsTagged: tagged == "" || tagged == "tagged", + Sort: sort, + }) + if err != nil { + ctx.ServerError("SearchImageTags", err) + return + } + default: + pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + Paginator: pagination, + PackageID: p.ID, + Version: packages_model.SearchValue{ + ExactMatch: false, + Value: query, + }, + IsInternal: optional.Some(false), + Sort: sort, + }) + if err != nil { + ctx.ServerError("SearchVersions", err) + return + } + } + + ctx.Data["PackageDescriptors"], err = packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + ctx.Data["Total"] = total + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + for k, v := range pagerParams { + pager.AddParamString(k, v) + } + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackageVersionList) +} + +// PackageSettings displays the package settings page +func PackageSettings(ctx *context.Context) { + pd := ctx.Package.Descriptor + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = pd.Package.Name + ctx.Data["IsPackagesPage"] = true + ctx.Data["PackageDescriptor"] = pd + + repos, _, _ := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + Actor: pd.Owner, + Private: true, + }) + ctx.Data["Repos"] = repos + ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplPackagesSettings) +} + +// PackageSettingsPost updates the package settings +func PackageSettingsPost(ctx *context.Context) { + pd := ctx.Package.Descriptor + + form := web.GetForm(ctx).(*forms.PackageSettingForm) + switch form.Action { + case "link": + success := func() bool { + repoID := int64(0) + if form.RepoID != 0 { + repo, err := repo_model.GetRepositoryByID(ctx, form.RepoID) + if err != nil { + log.Error("Error getting repository: %v", err) + return false + } + + if repo.OwnerID != pd.Owner.ID { + return false + } + + repoID = repo.ID + } + + if err := packages_model.SetRepositoryLink(ctx, pd.Package.ID, repoID); err != nil { + log.Error("Error updating package: %v", err) + return false + } + + return true + }() + + if success { + ctx.Flash.Success(ctx.Tr("packages.settings.link.success")) + } else { + ctx.Flash.Error(ctx.Tr("packages.settings.link.error")) + } + + ctx.Redirect(ctx.Link) + return + case "delete": + err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) + if err != nil { + log.Error("Error deleting package: %v", err) + ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) + } else { + ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) + } + + redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages" + // redirect to the package if there are still versions available + if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: ctx.Package.Descriptor.Package.ID, IsInternal: optional.Some(false)}); has { + redirectURL = ctx.Package.Descriptor.PackageWebLink() + } + + ctx.Redirect(redirectURL) + return + } +} + +// DownloadPackageFile serves the content of a package file +func DownloadPackageFile(ctx *context.Context) { + pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.ParamsInt64(":fileid")) + if err != nil { + if err == packages_model.ErrPackageFileNotExist { + ctx.NotFound("", err) + } else { + ctx.ServerError("GetFileForVersionByID", err) + } + return + } + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + ctx.ServerError("GetPackageFileStream", err) + return + } + + packages_helper.ServePackageFile(ctx, s, u, pf) +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go new file mode 100644 index 0000000..9cb392d --- /dev/null +++ b/routers/web/user/profile.go @@ -0,0 +1,385 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + "fmt" + "net/http" + "path" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/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/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/routers/web/org" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplProfileBigAvatar base.TplName = "shared/user/profile_big_avatar" + tplFollowUnfollow base.TplName = "org/follow_unfollow" +) + +// OwnerProfile render profile page for a user or a organization (aka, repo owner) +func OwnerProfile(ctx *context.Context) { + if strings.Contains(ctx.Req.Header.Get("Accept"), "application/rss+xml") { + feed.ShowUserFeedRSS(ctx) + return + } + if strings.Contains(ctx.Req.Header.Get("Accept"), "application/atom+xml") { + feed.ShowUserFeedAtom(ctx) + return + } + + if ctx.ContextUser.IsOrganization() { + org.Home(ctx) + } else { + userProfile(ctx) + } +} + +func userProfile(ctx *context.Context) { + // check view permissions + if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.NotFound("User not visible", nil) + return + } + + ctx.Data["Title"] = ctx.ContextUser.DisplayName() + ctx.Data["PageIsUserProfile"] = true + + // prepare heatmap data + if setting.Service.EnableUserHeatmap { + data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUser", err) + return + } + ctx.Data["HeatmapData"] = data + ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) + } + + profileDbRepo, profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) + defer profileClose() + + showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileGitRepo, profileReadmeBlob) + // call PrepareContextForProfileBigAvatar later to avoid re-querying the NumFollowers & NumFollowing + shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.HTML(http.StatusOK, tplProfile) +} + +func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadme *git.Blob) { + // if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page + // if there is not a profile readme, the overview tab should be treated as the repositories tab + tab := ctx.FormString("tab") + if tab == "" || tab == "overview" { + if profileReadme != nil { + tab = "overview" + } else { + tab = "repositories" + } + } + ctx.Data["TabName"] = tab + ctx.Data["HasProfileReadme"] = profileReadme != nil + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + pagingNum := setting.UI.User.RepoPagingNum + topicOnly := ctx.FormBool("topic") + var ( + repos []*repo_model.Repository + count int64 + total int + orderBy db.SearchOrderBy + ) + + sortOrder := ctx.FormString("sort") + if _, ok := repo_model.OrderByFlatMap[sortOrder]; !ok { + sortOrder = setting.UI.ExploreDefaultSort // TODO: add new default sort order for user home? + } + ctx.Data["SortType"] = sortOrder + orderBy = repo_model.OrderByFlatMap[sortOrder] + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + language := ctx.FormTrim("language") + ctx.Data["Language"] = language + + followers, numFollowers, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{ + PageSize: pagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetUserFollowers", err) + return + } + ctx.Data["NumFollowers"] = numFollowers + following, numFollowing, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{ + PageSize: pagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetUserFollowing", err) + return + } + ctx.Data["NumFollowing"] = numFollowing + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + switch tab { + case "followers": + ctx.Data["Cards"] = followers + total = int(numFollowers) + ctx.Data["CardsTitle"] = ctx.TrN(total, "user.followers.title.one", "user.followers.title.few") + case "following": + ctx.Data["Cards"] = following + total = int(numFollowing) + ctx.Data["CardsTitle"] = ctx.TrN(total, "user.following.title.one", "user.following.title.few") + case "activity": + date := ctx.FormString("date") + pagingNum = setting.UI.FeedPagingNum + items, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: showPrivate, + OnlyPerformedBy: true, + IncludeDeleted: false, + Date: date, + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + ctx.Data["Feeds"] = items + ctx.Data["Date"] = date + + total = int(count) + case "stars": + ctx.Data["PageIsProfileStarList"] = true + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + StarredByID: ctx.ContextUser.ID, + Collaborate: optional.Some(false), + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + case "watching": + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + WatchedByID: ctx.ContextUser.ID, + Collaborate: optional.Some(false), + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + case "overview": + if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("failed to GetBlobContent: %v", err) + } else { + if profileContent, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + GitRepo: profileGitRepo, + Links: markup.Links{ + // Give the repo link to the markdown render for the full link of media element. + // the media link usually be like /[user]/[repoName]/media/branch/[branchName], + // Eg. /Tom/.profile/media/branch/main + // The branch shown on the profile page is the default branch, this need to be in sync with doc, see: + // https://docs.gitea.com/usage/profile-readme + Base: profileDbRepo.Link(), + BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + }, + Metas: map[string]string{"mode": "document"}, + }, bytes); err != nil { + log.Error("failed to RenderString: %v", err) + } else { + ctx.Data["ProfileReadme"] = profileContent + } + } + default: // default to "repositories" + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OwnerID: ctx.ContextUser.ID, + OrderBy: orderBy, + Private: ctx.IsSigned, + Collaborate: optional.Some(false), + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + } + ctx.Data["Repos"] = repos + ctx.Data["Total"] = total + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(total, pagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "tab", "TabName") + if tab != "followers" && tab != "following" && tab != "activity" && tab != "projects" { + pager.AddParam(ctx, "language", "Language") + } + if tab == "activity" { + pager.AddParam(ctx, "date", "Date") + } + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + ctx.Data["Page"] = pager +} + +// Action response for follow/unfollow user request +func Action(ctx *context.Context) { + var err error + action := ctx.FormString("action") + + if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") { + log.Error("Cannot perform this action on an organization %q", ctx.FormString("action")) + ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + switch action { + case "follow": + err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + case "unfollow": + err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + case "block": + err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + case "unblock": + err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + } + + if err != nil { + if !errors.Is(err, user_model.ErrBlockedByUser) { + log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) + ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + if ctx.ContextUser.IsOrganization() { + ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"), true) + } else { + ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"), true) + } + } + + if ctx.ContextUser.IsIndividual() { + shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.Data["IsHTMX"] = true + ctx.HTML(http.StatusOK, tplProfileBigAvatar) + return + } else if ctx.ContextUser.IsOrganization() { + ctx.Data["Org"] = ctx.ContextUser + ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.HTML(http.StatusOK, tplFollowUnfollow) + return + } + log.Error("Failed to apply action %q: unsupported context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type) + ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) +} diff --git a/routers/web/user/search.go b/routers/web/user/search.go new file mode 100644 index 0000000..fb7729b --- /dev/null +++ b/routers/web/user/search.go @@ -0,0 +1,44 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// Search search users +func Search(ctx *context.Context) { + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } + + users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + UID: ctx.FormInt64("uid"), + Type: user_model.UserTypeIndividual, + IsActive: ctx.FormOptionalBool("active"), + ListOptions: listOptions, + }) + if err != nil { + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": err.Error(), + }) + return + } + + ctx.SetTotalCountHeader(maxResults) + + ctx.JSON(http.StatusOK, map[string]any{ + "ok": true, + "data": convert.ToUsers(ctx, ctx.Doer, users), + }) +} diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go new file mode 100644 index 0000000..34d2377 --- /dev/null +++ b/routers/web/user/setting/account.go @@ -0,0 +1,353 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + "time" + + "code.gitea.io/gitea/models" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/db" + "code.gitea.io/gitea/services/auth/source/smtp" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + "code.gitea.io/gitea/services/user" +) + +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.account") + ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.Doer.Email + ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail + + 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 ctx.Doer.IsPasswordSet() && !ctx.Doer.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 { + opts := &user.UpdateAuthOptions{ + Password: optional.Some(form.Password), + MustChangePassword: optional.Some(false), + } + if err := user.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) + case errors.Is(err, password.ErrComplexity): + ctx.Flash.Error(password.BuildComplexityError(ctx.Locale)) + case errors.Is(err, password.ErrIsPwned): + ctx.Flash.Error(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords")) + case password.IsErrIsPwnedRequest(err): + ctx.Flash.Error(ctx.Tr("auth.password_pwned_err")) + default: + ctx.ServerError("UpdateAuth", err) + return + } + } else { + // Re-generate LTA cookie. + if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 { + if err := ctx.SetLTACookie(ctx.Doer); err != nil { + ctx.ServerError("SetLTACookie", err) + return + } + } + + log.Trace("User password updated: %s", ctx.Doer.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.FormString("_method") == "PRIMARY" { + id := ctx.FormInt64("id") + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id) + if err != nil { + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if err := user.MakeEmailAddressPrimary(ctx, ctx.Doer, email, true); err != nil { + ctx.ServerError("MakeEmailPrimary", err) + return + } + + log.Trace("Email made primary: %s", ctx.Doer.Name) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Send activation Email + if ctx.FormString("_method") == "SENDACTIVATION" { + var address string + if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) { + log.Error("Send activation: activation still pending") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + id := ctx.FormInt64("id") + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id) + if err != nil { + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email == nil { + log.Warn("Send activation failed: EmailAddress[%d] not found for user: %-v", id, ctx.Doer) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email.IsActivated { + log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email.IsPrimary { + if ctx.Doer.IsActive && !setting.Service.RegisterEmailConfirm { + log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Only fired when the primary email is inactive (Wrong state) + if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendActivateAccountMail", err) + return + } + } else { + if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, email.Email); err != nil { + ctx.ServerError("SendActivateEmailMail", err) + return + } + } + address = email.Email + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.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))) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Set Email Notification Preference + if ctx.FormString("_method") == "NOTIFICATION" { + preference := ctx.FormString("preference") + if !(preference == user_model.EmailNotificationsEnabled || + preference == user_model.EmailNotificationsOnMention || + preference == user_model.EmailNotificationsDisabled || + preference == user_model.EmailNotificationsAndYourOwn) { + log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name) + ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) + return + } + opts := &user.UpdateOptions{ + EmailNotificationsPreference: optional.Some(preference), + } + if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil { + log.Error("Set Email Notifications failed: %v", err) + ctx.ServerError("UpdateUser", err) + return + } + log.Trace("Email notifications preference made %s: %s", preference, ctx.Doer.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 + } + + if err := user.AddEmailAddresses(ctx, ctx.Doer, []string{form.Email}); err != nil { + if user_model.IsErrEmailAlreadyUsed(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form) + } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form) + } else { + ctx.ServerError("AddEmailAddresses", err) + } + return + } + + // Send confirmation email + if setting.Service.RegisterEmailConfirm { + if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, form.Email); err != nil { + ctx.ServerError("SendActivateEmailMail", err) + return + } + if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", form.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale))) + } else { + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + } + + log.Trace("Email address added: %s", form.Email) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// DeleteEmail response for delete user's email +func DeleteEmail(ctx *context.Context) { + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id")) + if err != nil || email == nil { + ctx.ServerError("GetEmailAddressByID", err) + return + } + + if err := user.DeleteEmailAddresses(ctx, ctx.Doer, []string{email.Email}); err != nil { + ctx.ServerError("DeleteEmailAddresses", err) + return + } + log.Trace("Email address deleted: %s", ctx.Doer.Name) + + ctx.Flash.Success(ctx.Tr("settings.email_deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/account") +} + +// DeleteAccount render user suicide page and response for delete user himself +func DeleteAccount(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureDeletion) { + ctx.Error(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil { + switch { + case user_model.IsErrUserNotExist(err): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil) + case errors.Is(err, smtp.ErrUnsupportedLoginType): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil) + case errors.As(err, &db.ErrUserPasswordNotSet{}): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.unset_password"), tplSettingsAccount, nil) + case errors.As(err, &db.ErrUserPasswordInvalid{}): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil) + default: + ctx.ServerError("UserSignIn", err) + } + return + } + + // admin should not delete themself + if ctx.Doer.IsAdmin { + ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if err := user.DeleteUser(ctx, ctx.Doer, false); 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") + case models.IsErrUserOwnPackages(err): + ctx.Flash.Error(ctx.Tr("form.still_own_packages")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrDeleteLastAdminUser(err): + ctx.Flash.Error(ctx.Tr("auth.last_admin")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + default: + ctx.ServerError("DeleteUser", err) + } + } else { + log.Trace("Account deleted: %s", ctx.Doer.Name) + ctx.Redirect(setting.AppSubURL + "/") + } +} + +func loadAccountData(ctx *context.Context) { + emlist, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + type UserEmail struct { + user_model.EmailAddress + CanBePrimary bool + } + pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.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.Doer.EmailNotificationsPreference + ctx.Data["ActivationsPending"] = pendingActivation + ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) + + if setting.Service.UserDeleteWithCommentsMaxTime != 0 { + ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String() + ctx.Data["UserDeleteWithComments"] = ctx.Doer.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 0000000..9fdc5e4 --- /dev/null +++ b/routers/web/user/setting/account_test.go @@ -0,0 +1,101 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" +) + +func TestChangePassword(t *testing.T) { + oldPassword := "password" + setting.MinPasswordLength = 6 + pcALL := []string{"lower", "upper", "digit", "spec"} + pcLUN := []string{"lower", "upper", "digit"} + 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, + }, + } { + t.Run(req.OldPassword+"__"+req.NewPassword, func(t *testing.T) { + unittest.PrepareTestEnv(t) + setting.PasswordComplexity = req.PasswordComplexity + ctx, _ := contexttest.MockContext(t, "user/settings/security") + contexttest.LoadUser(t, ctx, 2) + contexttest.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.StatusSeeOther, ctx.Resp.Status()) + }) + } +} diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go new file mode 100644 index 0000000..171c193 --- /dev/null +++ b/routers/web/user/setting/adopt.go @@ -0,0 +1,64 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "path/filepath" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// AdoptOrDeleteRepository adopts or deletes a repository +func AdoptOrDeleteRepository(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.adopt") + 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.FormString("id") + action := ctx.FormString("action") + + ctxUser := ctx.Doer + root := user_model.UserPath(ctxUser.LowerName) + + // check not a repo + has, err := repo_model.IsRepositoryModelExist(ctx, 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 := repo_service.AdoptRepository(ctx, ctxUser, ctxUser, repo_service.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 := repo_service.DeleteUnadoptedRepository(ctx, 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 0000000..24ebf9b --- /dev/null +++ b/routers/web/user/setting/applications.go @@ -0,0 +1,115 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "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.applications") + 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 + } + + scope, err := form.GetScope() + if err != nil { + ctx.ServerError("GetScope", err) + return + } + t := &auth_model.AccessToken{ + UID: ctx.Doer.ID, + Name: form.Name, + Scope: scope, + } + + exist, err := auth_model.AccessTokenByNameExists(ctx, 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 := auth_model.NewAccessToken(ctx, 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 := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil { + ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") +} + +func loadApplicationsData(ctx *context.Context) { + ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled + ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin + if setting.OAuth2.Enabled { + ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ + OwnerID: ctx.Doer.ID, + }) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Grants"], err = auth_model.GetOAuth2GrantsByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOAuth2GrantsByUserID", err) + return + } + ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes + } +} diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go new file mode 100644 index 0000000..3f35b2e --- /dev/null +++ b/routers/web/user/setting/blocked_users.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users" +) + +// BlockedUsers render the blocked users list page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsBlockedUsers"] = true + ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users" + ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users" + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +// UnblockUser unblocks a particular user for the doer. +func UnblockUser(ctx *context.Context) { + if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil { + ctx.ServerError("UnblockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go new file mode 100644 index 0000000..9462be7 --- /dev/null +++ b/routers/web/user/setting/keys.go @@ -0,0 +1,338 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "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.ssh_gpg_keys") + 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 := asymkey_model.CheckPrincipalKeyString(ctx, ctx.Doer, form.Content) + if err != nil { + if db.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 = asymkey_model.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil { + ctx.Data["HasPrincipalError"] = true + switch { + case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.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": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + + token := asymkey_model.VerificationToken(ctx.Doer, 1) + lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) + + keys, err := asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, token, form.Signature) + if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { + keys, err = asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, lastToken, form.Signature) + } + if err != nil { + ctx.Data["HasGPGError"] = true + switch { + case asymkey_model.IsErrGPGKeyParsing(err): + ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error())) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case asymkey_model.IsErrGPGKeyIDAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form) + case asymkey_model.IsErrGPGInvalidTokenSignature(err): + loadKeysData(ctx) + ctx.Data["Err_Content"] = true + ctx.Data["Err_Signature"] = true + keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID + ctx.Data["KeyID"] = keyID + ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID) + ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form) + case asymkey_model.IsErrGPGNoEmailFound(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.Data["Err_Signature"] = true + keyID := err.(asymkey_model.ErrGPGNoEmailFound).ID + ctx.Data["KeyID"] = keyID + ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID) + 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 "verify_gpg": + token := asymkey_model.VerificationToken(ctx.Doer, 1) + lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) + + keyID, err := asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, token, form.Signature) + if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { + keyID, err = asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, lastToken, form.Signature) + } + if err != nil { + ctx.Data["HasGPGVerifyError"] = true + switch { + case asymkey_model.IsErrGPGInvalidTokenSignature(err): + loadKeysData(ctx) + ctx.Data["VerifyingID"] = form.KeyID + ctx.Data["Err_Signature"] = true + keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID + ctx.Data["KeyID"] = keyID + ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID) + ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form) + default: + ctx.ServerError("VerifyGPG", err) + } + } + ctx.Flash.Success(ctx.Tr("settings.verify_gpg_key_success", keyID)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + + content, err := asymkey_model.CheckPublicKeyString(form.Content) + if err != nil { + if db.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if asymkey_model.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else if err == asymkey_model.ErrKeyIsPrivate { + ctx.Flash.Error(ctx.Tr("form.must_use_public_key")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + + if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0); err != nil { + ctx.Data["HasSSHError"] = true + switch { + case asymkey_model.IsErrKeyAlreadyExist(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form) + case asymkey_model.IsErrKeyNameAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form) + case asymkey_model.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") + case "verify_ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + + token := asymkey_model.VerificationToken(ctx.Doer, 1) + lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) + + fingerprint, err := asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, token, form.Signature) + if err != nil && asymkey_model.IsErrSSHInvalidTokenSignature(err) { + fingerprint, err = asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, lastToken, form.Signature) + } + if err != nil { + ctx.Data["HasSSHVerifyError"] = true + switch { + case asymkey_model.IsErrSSHInvalidTokenSignature(err): + loadKeysData(ctx) + ctx.Data["Err_Signature"] = true + ctx.Data["Fingerprint"] = err.(asymkey_model.ErrSSHInvalidTokenSignature).Fingerprint + ctx.RenderWithErr(ctx.Tr("settings.ssh_invalid_token_signature"), tplSettingsKeys, &form) + default: + ctx.ServerError("VerifySSH", err) + } + } + ctx.Flash.Success(ctx.Tr("settings.verify_ssh_key_success", fingerprint)) + 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.FormString("type") { + case "gpg": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteGPGKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) + } + case "ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + + keyID := ctx.FormInt64("id") + external, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, keyID) + if err != nil { + ctx.ServerError("sshKeysExternalManaged", err) + return + } + if external { + ctx.Flash.Error(ctx.Tr("settings.ssh_externally_managed")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) + } + case "principal": + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("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.JSONRedirect(setting.AppSubURL + "/user/settings/keys") +} + +func loadKeysData(ctx *context.Context) { + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: ctx.Doer.ID, + NotKeytype: asymkey_model.KeyTypePrincipal, + }) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["Keys"] = keys + + externalKeys, err := asymkey_model.PublicKeysAreExternallyManaged(ctx, keys) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["ExternalKeys"] = externalKeys + + gpgkeys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.Doer.ID, + }) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + if err := asymkey_model.GPGKeyList(gpgkeys).LoadSubKeys(ctx); err != nil { + ctx.ServerError("LoadSubKeys", err) + return + } + ctx.Data["GPGKeys"] = gpgkeys + tokenToSign := asymkey_model.VerificationToken(ctx.Doer, 1) + + // generate a new aes cipher using the csrfToken + ctx.Data["TokenToSign"] = tokenToSign + + principals, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.Doer.ID, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypePrincipal}, + }) + if err != nil { + ctx.ServerError("ListPrincipalKeys", err) + return + } + ctx.Data["Principals"] = principals + + ctx.Data["VerifyingID"] = ctx.FormString("verify_gpg") + ctx.Data["VerifyingFingerprint"] = ctx.FormString("verify_ssh") + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) +} diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go new file mode 100644 index 0000000..e398208 --- /dev/null +++ b/routers/web/user/setting/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go new file mode 100644 index 0000000..1f485e0 --- /dev/null +++ b/routers/web/user/setting/oauth2.go @@ -0,0 +1,68 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsOAuthApplicationEdit base.TplName = "user/settings/applications_oauth2_edit" +) + +func newOAuth2CommonHandlers(userID int64) *OAuth2CommonHandlers { + return &OAuth2CommonHandlers{ + OwnerID: userID, + BasePathList: setting.AppSubURL + "/user/settings/applications", + BasePathEditPrefix: setting.AppSubURL + "/user/settings/applications/oauth2", + TplAppEdit: tplSettingsOAuthApplicationEdit, + } +} + +// OAuthApplicationsPost response for adding a oauth2 application +func OAuthApplicationsPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.AddApp(ctx) +} + +// OAuthApplicationsEdit response for editing oauth2 application +func OAuthApplicationsEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.EditSave(ctx) +} + +// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret +func OAuthApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.RegenerateSecret(ctx) +} + +// OAuth2ApplicationShow displays the given application +func OAuth2ApplicationShow(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.EditShow(ctx) +} + +// DeleteOAuth2Application deletes the given oauth2 application +func DeleteOAuth2Application(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.DeleteApp(ctx) +} + +// RevokeOAuth2Grant revokes the grant with the given id +func RevokeOAuth2Grant(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.RevokeGrant(ctx) +} diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go new file mode 100644 index 0000000..85d1e82 --- /dev/null +++ b/routers/web/user/setting/oauth2_common.go @@ -0,0 +1,163 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +type OAuth2CommonHandlers struct { + OwnerID int64 // 0 for instance-wide, otherwise OrgID or UserID + BasePathList string // the base URL for the application list page, eg: "/user/setting/applications" + BasePathEditPrefix string // the base URL for the application edit page, will be appended with app id, eg: "/user/setting/applications/oauth2" + TplAppEdit base.TplName // the template for the application edit page +} + +func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) { + app := ctx.Data["App"].(*auth.OAuth2Application) + ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID) + + if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() { + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + } + + ctx.HTML(http.StatusOK, oa.TplAppEdit) +} + +// AddApp adds an oauth2 application +func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + // go to the application list page + ctx.Redirect(oa.BasePathList) + return + } + + // TODO validate redirect URI + app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{ + Name: form.Name, + RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), + UserID: oa.OwnerID, + ConfidentialClient: form.ConfidentialClient, + }) + if err != nil { + ctx.ServerError("CreateOAuth2Application", err) + return + } + + // render the edit page with secret + ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true) + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx) + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + + oa.renderEditPage(ctx) +} + +// EditShow displays the given application +func (oa *OAuth2CommonHandlers) EditShow(ctx *context.Context) { + app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id")) + if err != nil { + if auth.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != oa.OwnerID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + oa.renderEditPage(ctx) +} + +// EditSave saves the oauth2 application +func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + + if ctx.HasError() { + oa.renderEditPage(ctx) + return + } + + // TODO validate redirect URI + var err error + if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ + ID: ctx.ParamsInt64("id"), + Name: form.Name, + RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), + UserID: oa.OwnerID, + ConfidentialClient: form.ConfidentialClient, + }); err != nil { + ctx.ServerError("UpdateOAuth2Application", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) + ctx.Redirect(oa.BasePathList) +} + +// RegenerateSecret regenerates the secret +func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { + app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id")) + if err != nil { + if auth.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != oa.OwnerID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx) + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true) + oa.renderEditPage(ctx) +} + +// DeleteApp deletes the given oauth2 application +func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) { + if err := auth.DeleteOAuth2Application(ctx, ctx.ParamsInt64("id"), oa.OwnerID); err != nil { + ctx.ServerError("DeleteOAuth2Application", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success")) + ctx.JSONRedirect(oa.BasePathList) +} + +// RevokeGrant revokes the grant +func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) { + if err := auth.RevokeOAuth2Grant(ctx, ctx.ParamsInt64("grantId"), oa.OwnerID); err != nil { + ctx.ServerError("RevokeOAuth2Grant", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) + ctx.JSONRedirect(oa.BasePathList) +} diff --git a/routers/web/user/setting/packages.go b/routers/web/user/setting/packages.go new file mode 100644 index 0000000..4132659 --- /dev/null +++ b/routers/web/user/setting/packages.go @@ -0,0 +1,119 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + shared "code.gitea.io/gitea/routers/web/shared/packages" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsPackages base.TplName = "user/settings/packages" + tplSettingsPackagesRuleEdit base.TplName = "user/settings/packages_cleanup_rules_edit" + tplSettingsPackagesRulePreview base.TplName = "user/settings/packages_cleanup_rules_preview" +) + +func Packages(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetPackagesContext(ctx, ctx.Doer) + + ctx.HTML(http.StatusOK, tplSettingsPackages) +} + +func PackagesRuleAdd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetRuleAddContext(ctx) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetRuleEditContext(ctx, ctx.Doer) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleAddPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleAddPost( + ctx, + ctx.Doer, + setting.AppSubURL+"/user/settings/packages", + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRuleEditPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleEditPost( + ctx, + ctx.Doer, + setting.AppSubURL+"/user/settings/packages", + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRulePreview(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetRulePreviewContext(ctx, ctx.Doer) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview) +} + +func InitializeCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.InitializeCargoIndex(ctx, ctx.Doer) + + ctx.Redirect(setting.AppSubURL + "/user/settings/packages") +} + +func RebuildCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.RebuildCargoIndex(ctx, ctx.Doer) + + ctx.Redirect(setting.AppSubURL + "/user/settings/packages") +} + +func RegenerateChefKeyPair(ctx *context.Context) { + priv, pub, err := util.GenerateKeyPair(chef_module.KeyBits) + if err != nil { + ctx.ServerError("GenerateKeyPair", err) + return + } + + if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil { + ctx.ServerError("SetUserSetting", err) + return + } + + ctx.ServeContent(strings.NewReader(priv), &context.ServeHeaderOptions{ + ContentType: "application/x-pem-file", + Filename: ctx.Doer.Name + ".priv", + }) +} diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go new file mode 100644 index 0000000..907f0f5 --- /dev/null +++ b/routers/web/user/setting/profile.go @@ -0,0 +1,433 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "io" + "math/big" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "code.gitea.io/gitea/models/avatars" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "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/context" + "code.gitea.io/gitea/services/forms" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplSettingsProfile base.TplName = "user/settings/profile" + tplSettingsAppearance base.TplName = "user/settings/appearance" + tplSettingsOrganization base.TplName = "user/settings/organization" + tplSettingsRepositories base.TplName = "user/settings/repos" +) + +// must be kept in sync with `web_src/js/features/user-settings.js` +var recognisedPronouns = []string{"", "he/him", "she/her", "they/them", "it/its", "any pronouns"} + +// Profile render user's profile page +func Profile(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.profile") + ctx.Data["PageIsSettingsProfile"] = true + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) + ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns) + + ctx.HTML(http.StatusOK, tplSettingsProfile) +} + +// ProfilePost response for change user's profile +func ProfilePost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) + ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsProfile) + return + } + + form := web.GetForm(ctx).(*forms.UpdateProfileForm) + + if form.Name != "" { + if err := user_service.RenameUser(ctx, ctx.Doer, form.Name); err != nil { + switch { + case user_model.IsErrUserIsNotLocal(err): + ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) + case user_model.IsErrUserAlreadyExist(err): + ctx.Flash.Error(ctx.Tr("form.username_been_taken")) + case db.IsErrNameReserved(err): + ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name)) + case db.IsErrNamePatternNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", form.Name)) + case db.IsErrNameCharsNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", form.Name)) + default: + ctx.ServerError("RenameUser", err) + return + } + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + } + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + KeepEmailPrivate: optional.Some(form.KeepEmailPrivate), + Description: optional.Some(form.Biography), + Pronouns: optional.Some(form.Pronouns), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Visibility: optional.Some(form.Visibility), + KeepActivityPrivate: optional.Some(form.KeepActivityPrivate), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("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 *user_model.User) error { + ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal + if len(form.Gravatar) > 0 { + if form.Avatar != nil { + ctxUser.Avatar = avatars.HashEmail(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: %w", err) + } + defer fr.Close() + + if form.Avatar.Size > setting.Avatar.MaxFileSize { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) + } + + data, err := io.ReadAll(fr) + if err != nil { + return fmt.Errorf("io.ReadAll: %w", err) + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image")) + } + if err = user_service.UploadAvatar(ctx, ctxUser, data); err != nil { + return fmt.Errorf("UploadAvatar: %w", 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 := user_model.GenerateRandomAvatar(ctx, ctxUser); err != nil { + log.Error("GenerateRandomAvatar[%d]: %v", ctxUser.ID, err) + } + } + + if err := user_model.UpdateUserCols(ctx, ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil { + return fmt.Errorf("UpdateUserCols: %w", 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.Doer); 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 := user_service.DeleteAvatar(ctx, ctx.Doer); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings") +} + +// Organization render all the organization of the user +func Organization(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.organization") + ctx.Data["PageIsSettingsOrganization"] = true + + opts := organization.FindOrgOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("page"), + }, + UserID: ctx.Doer.ID, + IncludePrivate: ctx.IsSigned, + } + + if opts.Page <= 0 { + opts.Page = 1 + } + + orgs, total, err := db.FindAndCount[organization.Organization](ctx, opts) + if err != nil { + ctx.ServerError("FindOrgs", err) + return + } + + ctx.Data["Orgs"] = orgs + pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + 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.repos") + ctx.Data["PageIsSettingsRepos"] = true + ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories + ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories + + opts := db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("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.Doer + count := 0 + + if adoptOrDelete { + repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum) + repos := map[string]*repo_model.Repository{} + // We're going to iterate by pagesize. + root := user_model.UserPath(ctxUser.Name) + if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !d.IsDir() || path == root { + return nil + } + name := d.Name() + if !strings.HasSuffix(name, ".git") { + return filepath.SkipDir + } + name = name[:len(name)-4] + if repo_model.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.WalkDir", err) + return + } + + userRepos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + Actor: ctxUser, + Private: true, + ListOptions: db.ListOptions{ + Page: 1, + PageSize: setting.UI.Admin.UserPagingNum, + }, + LowerNames: repoNames, + }) + if err != nil { + ctx.ServerError("GetUserRepositories", err) + return + } + for _, repo := range userRepos { + if repo.IsFork { + if err := repo.GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + repos[repo.LowerName] = repo + } + ctx.Data["Dirs"] = repoNames + ctx.Data["ReposMap"] = repos + } else { + repos, count64, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) + if err != nil { + ctx.ServerError("GetUserRepositories", err) + return + } + count = int(count64) + + for i := range repos { + if repos[i].IsFork { + if err := repos[i].GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + } + + ctx.Data["Repos"] = repos + } + ctx.Data["ContextUser"] = ctxUser + pager := context.NewPagination(count, opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsRepositories) +} + +// Appearance render user's appearance settings +func Appearance(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.appearance") + ctx.Data["PageIsSettingsAppearance"] = true + + var hiddenCommentTypes *big.Int + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here + + ctx.Data["IsCommentTypeGroupChecked"] = func(commentTypeGroup string) bool { + return forms.IsUserHiddenCommentTypeGroupChecked(commentTypeGroup, hiddenCommentTypes) + } + + ctx.HTML(http.StatusOK, tplSettingsAppearance) +} + +// 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["PageIsSettingsAppearance"] = true + + if ctx.HasError() { + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") + return + } + + if !form.IsThemeExists() { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") + return + } + + opts := &user_service.UpdateOptions{ + Theme: optional.Some(form.Theme), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + } else { + ctx.Flash.Success(ctx.Tr("settings.theme_update_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} + +// UpdateUserLang update a user's language +func UpdateUserLang(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateLanguageForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAppearance"] = true + + if form.Language != "" { + if !util.SliceContainsString(setting.Langs, form.Language) { + ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language)) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") + return + } + } + + opts := &user_service.UpdateOptions{ + Language: optional.Some(form.Language), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + // Update the language to the one we just set + middleware.SetLocaleCookie(ctx.Resp, ctx.Doer.Language, 0) + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_language_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} + +// UpdateUserHints updates a user's hints settings +func UpdateUserHints(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateHintsForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAppearance"] = true + + opts := &user_service.UpdateOptions{ + EnableRepoUnitHints: optional.Some(form.EnableRepoUnitHints), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_hints_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} + +// UpdateUserHiddenComments update a user's shown comment types +func UpdateUserHiddenComments(ctx *context.Context) { + err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String()) + if err != nil { + ctx.ServerError("SetUserSetting", err) + return + } + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go new file mode 100644 index 0000000..2bb10cc --- /dev/null +++ b/routers/web/user/setting/runner.go @@ -0,0 +1,13 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners") +} diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go new file mode 100644 index 0000000..7c85c0e --- /dev/null +++ b/routers/web/user/setting/security/2fa.go @@ -0,0 +1,263 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "bytes" + "encoding/base64" + "html/template" + "image/png" + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + + "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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } else { + 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 = auth.UpdateTwoFactor(ctx, 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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } else { + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + } + return + } + + if err = auth.DeleteTwoFactorByID(ctx, t.ID, ctx.Doer.ID); err != nil { + if auth.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") + } else { + ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", err) + } + return + } + + if err := mailer.SendDisabledTOTP(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendDisabledTOTP", 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.Doer.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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if t != nil { + // already enrolled - we should redirect back! + log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer) + ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !auth.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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if t != nil { + // already enrolled + ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !auth.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 = &auth.TwoFactor{ + UID: ctx.Doer.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 := mailer.SendTOTPEnrolled(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendTOTPEnrolled", err) + return + } + + if err = auth.NewTwoFactor(ctx, 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/openid.go b/routers/web/user/setting/security/openid.go new file mode 100644 index 0000000..8f788e1 --- /dev/null +++ b/routers/web/user/setting/security/openid.go @@ -0,0 +1,126 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "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 := user_model.GetUserOpenIDs(ctx, ctx.Doer.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 := &user_model.UserOpenID{UID: ctx.Doer.ID, URI: id} + if err = user_model.AddUserOpenID(ctx, oid); err != nil { + if user_model.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.Doer.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 := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { + ctx.ServerError("DeleteUserOpenID", err) + return + } + log.Trace("OpenID address deleted: %s", ctx.Doer.Name) + + ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} + +// ToggleOpenIDVisibility response for toggle visibility of user's openid +func ToggleOpenIDVisibility(ctx *context.Context) { + if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil { + ctx.ServerError("ToggleUserOpenIDVisibility", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go new file mode 100644 index 0000000..8d6859a --- /dev/null +++ b/routers/web/user/setting/security/security.go @@ -0,0 +1,148 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "net/http" + "sort" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsSecurity base.TplName = "user/settings/security/security" + tplSettingsTwofaEnroll base.TplName = "user/settings/security/twofa_enroll" +) + +// Security render change user's password page and 2FA +func Security(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.security") + ctx.Data["PageIsSettingsSecurity"] = true + + if ctx.FormString("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.FormInt64("id") + if id <= 0 { + ctx.Flash.Error("Account link id is not given") + } else { + if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil { + ctx.Flash.Error("RemoveAccountLink: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + } + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} + +func loadSecurityData(ctx *context.Context) { + enrolled, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + ctx.Data["TOTPEnrolled"] = enrolled + + credentials, err := auth_model.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + ctx.Data["WebAuthnCredentials"] = credentials + + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + + accountLinks, err := db.Find[user_model.ExternalLoginUser](ctx, user_model.FindExternalUserOptions{ + UserID: ctx.Doer.ID, + OrderBy: "login_source_id DESC", + }) + if err != nil { + ctx.ServerError("ListAccountLinks", err) + return + } + + // map the provider display name with the AuthSource + sources := make(map[*auth_model.Source]string) + for _, externalAccount := range accountLinks { + if authSource, err := auth_model.GetSourceByID(ctx, externalAccount.LoginSourceID); err == nil { + var providerDisplayName string + + type DisplayNamed interface { + DisplayName() string + } + + type Named interface { + Name() string + } + + if displayNamed, ok := authSource.Cfg.(DisplayNamed); ok { + providerDisplayName = displayNamed.DisplayName() + } else if named, ok := authSource.Cfg.(Named); ok { + providerDisplayName = named.Name() + } else { + providerDisplayName = authSource.Name + } + sources[authSource] = providerDisplayName + } + } + ctx.Data["AccountLinks"] = sources + + authSources, err := db.Find[auth_model.Source](ctx, auth_model.FindSourcesOptions{ + IsActive: optional.None[bool](), + LoginType: auth_model.OAuth2, + }) + if err != nil { + ctx.ServerError("FindSources", err) + return + } + + var orderedOAuth2Names []string + oauth2Providers := make(map[string]oauth2.Provider) + for _, source := range authSources { + provider, err := oauth2.CreateProviderFromSource(source) + if err != nil { + ctx.ServerError("CreateProviderFromSource", err) + return + } + oauth2Providers[source.Name] = provider + if source.IsActive { + orderedOAuth2Names = append(orderedOAuth2Names, source.Name) + } + } + + sort.Strings(orderedOAuth2Names) + + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names + ctx.Data["OAuth2Providers"] = oauth2Providers + + openid, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openid +} diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go new file mode 100644 index 0000000..bfbc06c --- /dev/null +++ b/routers/web/user/setting/security/webauthn.go @@ -0,0 +1,137 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "errors" + "net/http" + "strconv" + "time" + + "code.gitea.io/gitea/models/auth" + wa "code.gitea.io/gitea/modules/auth/webauthn" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// WebAuthnRegister initializes the webauthn registration procedure +func WebAuthnRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm) + if form.Name == "" { + // Set name to the hexadecimal of the current time + form.Name = strconv.FormatInt(time.Now().UnixNano(), 16) + } + + cred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, form.Name) + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + if cred != nil { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + + _ = ctx.Session.Delete("webauthnRegistration") + if err := ctx.Session.Set("webauthnName", form.Name); err != nil { + ctx.ServerError("Unable to set session key for webauthnName", err) + return + } + + credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer)) + if err != nil { + ctx.ServerError("Unable to BeginRegistration", err) + return + } + + // Save the session data as marshaled JSON + if err = ctx.Session.Set("webauthnRegistration", sessionData); err != nil { + ctx.ServerError("Unable to set session", err) + return + } + + ctx.JSON(http.StatusOK, credentialOptions) +} + +// WebauthnRegisterPost receives the response of the security key +func WebauthnRegisterPost(ctx *context.Context) { + name, ok := ctx.Session.Get("webauthnName").(string) + if !ok || name == "" { + ctx.ServerError("Get webauthnName", errors.New("no webauthnName")) + return + } + + // Load the session data + sessionData, ok := ctx.Session.Get("webauthnRegistration").(*webauthn.SessionData) + if !ok || sessionData == nil { + ctx.ServerError("Get registration", errors.New("no registration")) + return + } + defer func() { + _ = ctx.Session.Delete("webauthnRegistration") + }() + + // Verify that the challenge succeeded + cred, err := wa.WebAuthn.FinishRegistration((*wa.User)(ctx.Doer), *sessionData, ctx.Req) + if err != nil { + if pErr, ok := err.(*protocol.Error); ok { + log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo) + } + ctx.ServerError("CreateCredential", err) + return + } + + dbCred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, name) + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + if dbCred != nil { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + + // Create the credential + _, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred) + if err != nil { + ctx.ServerError("CreateCredential", err) + return + } + _ = ctx.Session.Delete("webauthnName") + + ctx.JSON(http.StatusCreated, cred) +} + +// WebauthnDelete deletes an security key by id +func WebauthnDelete(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) + cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID) + if err != nil || cred.UserID != ctx.Doer.ID { + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + log.Error("GetWebAuthnCredentialByID: %v", err) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") + return + } + + if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("GetWebAuthnCredentialByID", err) + return + } + + if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil { + ctx.ServerError("SendRemovedSecurityKey", err) + return + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go new file mode 100644 index 0000000..3cc67d9 --- /dev/null +++ b/routers/web/user/setting/webhooks.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +const ( + tplSettingsHooks base.TplName = "user/settings/hooks" +) + +// Webhooks render webhook list page +func Webhooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks" + ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks" + ctx.Data["WebhookList"] = webhook_service.List() + ctx.Data["Description"] = ctx.Tr("settings.hooks.desc") + + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListWebhooksByOpts", err) + return + } + + ctx.Data["Webhooks"] = ws + ctx.HTML(http.StatusOK, tplSettingsHooks) +} + +// DeleteWebhook response for delete webhook +func DeleteWebhook(ctx *context.Context) { + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/hooks") +} diff --git a/routers/web/user/stop_watch.go b/routers/web/user/stop_watch.go new file mode 100644 index 0000000..38f74ea --- /dev/null +++ b/routers/web/user/stop_watch.go @@ -0,0 +1,40 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetStopwatches get all stopwatches +func GetStopwatches(ctx *context.Context) { + sws, err := issues_model.GetUserStopwatches(ctx, ctx.Doer.ID, db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + apiSWs, err := convert.ToStopWatches(ctx, sws) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiSWs) +} diff --git a/routers/web/user/task.go b/routers/web/user/task.go new file mode 100644 index 0000000..8476767 --- /dev/null +++ b/routers/web/user/task.go @@ -0,0 +1,53 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + "strconv" + + admin_model "code.gitea.io/gitea/models/admin" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" +) + +// TaskStatus returns task's status +func TaskStatus(ctx *context.Context) { + task, opts, err := admin_model.GetMigratingTaskByID(ctx, ctx.ParamsInt64("task"), ctx.Doer.ID) + if err != nil { + if admin_model.IsErrTaskDoesNotExist(err) { + ctx.JSON(http.StatusNotFound, map[string]any{ + "error": "task `" + strconv.FormatInt(ctx.ParamsInt64("task"), 10) + "` does not exist", + }) + return + } + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "err": err, + }) + return + } + + message := task.Message + + if task.Message != "" && task.Message[0] == '{' { + // assume message is actually a translatable string + var translatableMessage admin_model.TranslatableMessage + if err := json.Unmarshal([]byte(message), &translatableMessage); err != nil { + translatableMessage = admin_model.TranslatableMessage{ + Format: "migrate.migrating_failed.error", + Args: []any{task.Message}, + } + } + message = ctx.Locale.TrString(translatableMessage.Format, translatableMessage.Args...) + } + + ctx.JSON(http.StatusOK, map[string]any{ + "status": task.Status, + "message": message, + "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 0000000..ab73ef9 --- /dev/null +++ b/routers/web/web.go @@ -0,0 +1,1666 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + gocontext "context" + "net/http" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/models/unit" + "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/structs" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/routers/web/admin" + "code.gitea.io/gitea/routers/web/auth" + "code.gitea.io/gitea/routers/web/devtest" + "code.gitea.io/gitea/routers/web/events" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/routers/web/healthcheck" + "code.gitea.io/gitea/routers/web/misc" + "code.gitea.io/gitea/routers/web/org" + org_setting "code.gitea.io/gitea/routers/web/org/setting" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/routers/web/repo/actions" + "code.gitea.io/gitea/routers/web/repo/badges" + repo_flags "code.gitea.io/gitea/routers/web/repo/flags" + repo_setting "code.gitea.io/gitea/routers/web/repo/setting" + "code.gitea.io/gitea/routers/web/shared/project" + "code.gitea.io/gitea/routers/web/user" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/routers/web/user/setting/security" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/lfs" + + _ "code.gitea.io/gitea/modules/session" // to registers all internal adapters + + "code.forgejo.org/go-chi/captcha" + chi_middleware "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/klauspost/compress/gzhttp" + "github.com/prometheus/client_golang/prometheus" +) + +var GzipMinSize = gzhttp.DefaultMinSize + +// optionsCorsHandler return a http handler which sets CORS options if enabled by config, it blocks non-CORS OPTIONS requests. +func optionsCorsHandler() func(next http.Handler) http.Handler { + var corsHandler func(next http.Handler) http.Handler + if setting.CORSConfig.Enabled { + corsHandler = cors.Handler(cors.Options{ + AllowedOrigins: setting.CORSConfig.AllowDomain, + AllowedMethods: setting.CORSConfig.Methods, + AllowCredentials: setting.CORSConfig.AllowCredentials, + AllowedHeaders: setting.CORSConfig.Headers, + MaxAge: int(setting.CORSConfig.MaxAge.Seconds()), + }) + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + if corsHandler != nil && r.Header.Get("Access-Control-Request-Method") != "" { + corsHandler(next).ServeHTTP(w, r) + } else { + // it should explicitly deny OPTIONS requests if CORS handler is not executed, to avoid the next GET/POST handler being incorrectly called by the OPTIONS request + w.WriteHeader(http.StatusMethodNotAllowed) + } + return + } + // for non-OPTIONS requests, call the CORS handler to add some related headers like "Vary" + if corsHandler != nil { + corsHandler(next).ServeHTTP(w, r) + } else { + next.ServeHTTP(w, r) + } + }) + } +} + +// The OAuth2 plugin is expected to be executed first, as it must ignore the user id stored +// in the session (if there is a user id stored in session other plugins might return the user +// object for that id). +// +// The Session plugin is expected to be executed second, in order to skip authentication +// for users that have already signed in. +func buildAuthGroup() *auth_service.Group { + group := auth_service.NewGroup() + group.Add(&auth_service.OAuth2{}) // FIXME: this should be removed and only applied in download and oauth related routers + group.Add(&auth_service.Basic{}) // FIXME: this should be removed and only applied in download and git/lfs routers + + if setting.Service.EnableReverseProxyAuth { + group.Add(&auth_service.ReverseProxy{}) // reverseproxy should before Session, otherwise the header will be ignored if user has login + } + group.Add(&auth_service.Session{}) + + if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { + group.Add(&auth_service.SSPI{}) // it MUST be the last, see the comment of SSPI + } + + return group +} + +func webAuth(authMethod auth_service.Method) func(*context.Context) { + return func(ctx *context.Context) { + ar, err := common.AuthShared(ctx.Base, ctx.Session, authMethod) + if err != nil { + log.Error("Failed to verify user: %v", err) + ctx.Error(http.StatusUnauthorized, ctx.Locale.TrString("auth.unauthorized_credentials", "https://codeberg.org/forgejo/forgejo/issues/2809")) + return + } + ctx.Doer = ar.Doer + ctx.IsSigned = ar.Doer != nil + ctx.IsBasicAuth = ar.IsBasicAuth + if ctx.Doer == nil { + // ensure the session uid is deleted + _ = ctx.Session.Delete("uid") + } + + ctx.Csrf.PrepareForSessionUser(ctx) + } +} + +// verifyAuthWithOptions checks authentication according to options +func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) { + return func(ctx *context.Context) { + // Check prohibit login users. + if ctx.IsSigned { + if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, "user/auth/activate") + return + } + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + return + } + + if ctx.Doer.MustChangePassword { + if ctx.Req.URL.Path != "/user/settings/change_password" { + if strings.HasPrefix(ctx.Req.UserAgent(), "git") { + ctx.Error(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) + return + } + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") + return + } + } else if ctx.Req.URL.Path == "/user/settings/change_password" { + // make sure that the form cannot be accessed by users who don't need this + ctx.Redirect(setting.AppSubURL + "/") + return + } + } + + // Redirect to dashboard (or alternate location) if user tries to visit any non-login page. + if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { + ctx.RedirectToFirst(ctx.FormString("redirect_to")) + return + } + + if !options.SignOutRequired && !options.DisableCSRF && ctx.Req.Method == "POST" { + ctx.Csrf.Validate(ctx) + if ctx.Written() { + return + } + } + + if options.SignInRequired { + if !ctx.IsSigned { + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, "user/auth/activate") + return + } + } + + // Redirect to log in page if auto-signin info is provided and has not signed in. + if !options.SignOutRequired && !ctx.IsSigned && + ctx.GetSiteCookie(setting.CookieRememberName) != "" { + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + + if options.AdminRequired { + if !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + ctx.Data["PageIsAdmin"] = true + } + } +} + +func ctxDataSet(args ...any) func(ctx *context.Context) { + return func(ctx *context.Context) { + for i := 0; i < len(args); i += 2 { + ctx.Data[args[i].(string)] = args[i+1] + } + } +} + +// Routes returns all web routes +func Routes() *web.Route { + routes := web.NewRoute() + + routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler + routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc()) + routes.Methods("GET, HEAD", "/avatars/*", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) + routes.Methods("GET, HEAD", "/repo-avatars/*", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) + routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) + routes.Methods("GET, HEAD", "/apple-touch-icon-precomposed.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) + routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png")) + + _ = templates.HTMLRenderer() + + var mid []any + + if setting.EnableGzip { + wrapper, err := gzhttp.NewWrapper(gzhttp.RandomJitter(32, 0, false), gzhttp.MinSize(GzipMinSize)) + if err != nil { + log.Fatal("gzhttp.NewWrapper failed: %v", err) + } + mid = append(mid, wrapper) + } + + if setting.Service.EnableCaptcha { + // The captcha http.Handler should only fire on /captcha/* so we can just mount this on that url + routes.Methods("GET,HEAD", "/captcha/*", append(mid, captcha.Server(captcha.StdWidth, captcha.StdHeight).ServeHTTP)...) + } + + if setting.Metrics.Enabled { + prometheus.MustRegister(metrics.NewCollector()) + routes.Get("/metrics", append(mid, Metrics)...) + } + + routes.Methods("GET,HEAD", "/robots.txt", append(mid, misc.RobotsTxt)...) + routes.Get("/ssh_info", misc.SSHInfo) + routes.Get("/api/healthz", healthcheck.Check) + + mid = append(mid, common.Sessioner(), context.Contexter()) + + // Get user from session if logged in. + mid = append(mid, webAuth(buildAuthGroup())) + + // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route + mid = append(mid, chi_middleware.GetHead) + + if setting.API.EnableSwagger { + // Note: The route is here but no in API routes because it renders a web page + routes.Get("/api/swagger", append(mid, misc.Swagger)...) // Render V1 by default + routes.Get("/api/forgejo/swagger", append(mid, misc.SwaggerForgejo)...) + } + + // TODO: These really seem like things that could be folded into Contexter or as helper functions + mid = append(mid, user.GetNotificationCount) + mid = append(mid, repo.GetActiveStopwatch) + mid = append(mid, goGet) + + others := web.NewRoute() + others.Use(mid...) + registerRoutes(others) + routes.Mount("", others) + return routes +} + +var ignSignInAndCsrf = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true}) + +// registerRoutes register routes +func registerRoutes(m *web.Route) { + reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true}) + reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true}) + // TODO: rename them to "optSignIn", which means that the "sign-in" could be optional, depends on the VerifyOptions (RequireSignInView) + ignSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView}) + ignExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) + + validation.AddBindingRules() + + linkAccountEnabled := func(ctx *context.Context) { + if !setting.Service.EnableOpenIDSignIn && !setting.Service.EnableOpenIDSignUp && !setting.OAuth2.Enabled { + ctx.Error(http.StatusForbidden) + return + } + } + + 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 + } + } + + oauth2Enabled := func(ctx *context.Context) { + if !setting.OAuth2.Enabled { + 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 + } + } + + federationEnabled := func(ctx *context.Context) { + if !setting.Federation.Enabled { + ctx.Error(http.StatusNotFound) + return + } + } + + dlSourceEnabled := func(ctx *context.Context) { + if setting.Repository.DisableDownloadSourceArchives { + ctx.Error(http.StatusNotFound) + return + } + } + + sitemapEnabled := func(ctx *context.Context) { + if !setting.Other.EnableSitemap { + ctx.Error(http.StatusNotFound) + return + } + } + + packagesEnabled := func(ctx *context.Context) { + if !setting.Packages.Enabled { + ctx.Error(http.StatusForbidden) + return + } + } + + feedEnabled := func(ctx *context.Context) { + if !setting.Other.EnableFeed { + ctx.Error(http.StatusNotFound) + return + } + } + + reqUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + // only check global disabled units when ignoreGlobal is false + if !ignoreGlobal && unitType.UnitGlobalDisabled() { + ctx.NotFound(unitType.String(), nil) + return + } + + if ctx.ContextUser == nil { + ctx.NotFound(unitType.String(), nil) + return + } + + if ctx.ContextUser.IsOrganization() { + if ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unitType) < accessMode { + ctx.NotFound(unitType.String(), nil) + return + } + } + } + } + + addSettingsVariablesRoutes := func() { + m.Group("/variables", func() { + m.Get("", repo_setting.Variables) + m.Post("/new", web.Bind(forms.EditVariableForm{}), repo_setting.VariableCreate) + m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), repo_setting.VariableUpdate) + m.Post("/{variable_id}/delete", repo_setting.VariableDelete) + }) + } + + addSettingsSecretsRoutes := func() { + m.Group("/secrets", func() { + m.Get("", repo_setting.Secrets) + m.Post("", web.Bind(forms.AddSecretForm{}), repo_setting.SecretsPost) + m.Post("/delete", repo_setting.SecretsDelete) + }) + } + + addSettingsRunnersRoutes := func() { + m.Group("/runners", func() { + m.Get("", repo_setting.Runners) + m.Combo("/{runnerid}").Get(repo_setting.RunnersEdit). + Post(web.Bind(forms.EditRunnerForm{}), repo_setting.RunnersEditPost) + m.Post("/{runnerid}/delete", repo_setting.RunnerDeletePost) + m.Get("/reset_registration_token", repo_setting.ResetRunnerRegistrationToken) + }) + } + + // FIXME: not all routes need go through same middleware. + // Especially some AJAX requests, we can reduce middleware number to improve performance. + + m.Get("/", Home) + m.Get("/sitemap.xml", sitemapEnabled, ignExploreSignIn, HomeSitemap) + m.Group("/.well-known", func() { + m.Get("/openid-configuration", auth.OIDCWellKnown) + m.Group("", func() { + m.Get("/nodeinfo", NodeInfoLinks) + m.Get("/webfinger", WebfingerQuery) + }, federationEnabled) + m.Get("/change-password", func(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + }) + m.Methods("GET, HEAD", "/*", public.FileHandlerFunc()) + }, optionsCorsHandler()) + + m.Group("/explore", func() { + m.Get("", func(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/explore/repos") + }) + m.Get("/repos", explore.Repos) + m.Get("/repos/sitemap-{idx}.xml", sitemapEnabled, explore.Repos) + m.Get("/users", explore.Users) + m.Get("/users/sitemap-{idx}.xml", sitemapEnabled, explore.Users) + m.Get("/organizations", explore.Organizations) + m.Get("/code", func(ctx *context.Context) { + if unit.TypeCode.UnitGlobalDisabled() { + ctx.NotFound(unit.TypeCode.String(), nil) + return + } + }, explore.Code) + m.Get("/topics/search", explore.TopicSearch) + }, ignExploreSignIn) + m.Group("/issues", func() { + m.Get("", user.Issues) + m.Get("/search", repo.SearchIssues) + }, reqSignIn) + + m.Get("/pulls", reqSignIn, user.Pulls) + m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) + + // ***** START: User ***** + // "user/login" doesn't need signOut, then logged-in users can still access this route for redirection purposes by "/user/login?redirec_to=..." + m.Get("/user/login", auth.SignIn) + m.Group("/user", func() { + m.Post("/login", web.Bind(forms.SignInForm{}), auth.SignInPost) + m.Group("", func() { + m.Combo("/login/openid"). + Get(auth.SignInOpenID). + Post(web.Bind(forms.SignInOpenIDForm{}), auth.SignInOpenIDPost) + }, openIDSignInEnabled) + m.Group("/openid", func() { + m.Combo("/connect"). + Get(auth.ConnectOpenID). + Post(web.Bind(forms.ConnectOpenIDForm{}), auth.ConnectOpenIDPost) + m.Group("/register", func() { + m.Combo(""). + Get(auth.RegisterOpenID, openIDSignUpEnabled). + Post(web.Bind(forms.SignUpOpenIDForm{}), auth.RegisterOpenIDPost) + }, openIDSignUpEnabled) + }, openIDSignInEnabled) + m.Get("/sign_up", auth.SignUp) + m.Post("/sign_up", web.Bind(forms.RegisterForm{}), auth.SignUpPost) + m.Get("/link_account", linkAccountEnabled, auth.LinkAccount) + m.Post("/link_account_signin", linkAccountEnabled, web.Bind(forms.SignInForm{}), auth.LinkAccountPostSignIn) + m.Post("/link_account_signup", linkAccountEnabled, web.Bind(forms.RegisterForm{}), auth.LinkAccountPostRegister) + m.Group("/two_factor", func() { + m.Get("", auth.TwoFactor) + m.Post("", web.Bind(forms.TwoFactorAuthForm{}), auth.TwoFactorPost) + m.Get("/scratch", auth.TwoFactorScratch) + m.Post("/scratch", web.Bind(forms.TwoFactorScratchAuthForm{}), auth.TwoFactorScratchPost) + }) + m.Group("/webauthn", func() { + m.Get("", auth.WebAuthn) + m.Get("/assertion", auth.WebAuthnLoginAssertion) + m.Post("/assertion", auth.WebAuthnLoginAssertionPost) + }) + }, reqSignOut) + + m.Any("/user/events", routing.MarkLongPolling, events.Events) + + m.Group("/login/oauth", func() { + m.Group("", func() { + m.Get("/authorize", web.Bind(forms.AuthorizationForm{}), auth.AuthorizeOAuth) + m.Post("/grant", web.Bind(forms.GrantApplicationForm{}), auth.GrantApplicationOAuth) + // TODO manage redirection + m.Post("/authorize", web.Bind(forms.AuthorizationForm{}), auth.AuthorizeOAuth) + }, ignSignInAndCsrf, reqSignIn) + + m.Methods("GET, OPTIONS", "/userinfo", optionsCorsHandler(), ignSignInAndCsrf, auth.InfoOAuth) + m.Methods("POST, OPTIONS", "/access_token", optionsCorsHandler(), web.Bind(forms.AccessTokenForm{}), ignSignInAndCsrf, auth.AccessTokenOAuth) + m.Methods("GET, OPTIONS", "/keys", optionsCorsHandler(), ignSignInAndCsrf, auth.OIDCKeys) + m.Methods("POST, OPTIONS", "/introspect", optionsCorsHandler(), web.Bind(forms.IntrospectTokenForm{}), ignSignInAndCsrf, auth.IntrospectOAuth) + }, oauth2Enabled) + + m.Group("/user/settings", func() { + m.Get("", user_setting.Profile) + m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost) + m.Get("/change_password", auth.MustChangePassword) + m.Post("/change_password", web.Bind(forms.MustChangePasswordForm{}), auth.MustChangePasswordPost) + m.Post("/avatar", web.Bind(forms.AvatarForm{}), user_setting.AvatarPost) + m.Post("/avatar/delete", user_setting.DeleteAvatar) + m.Group("/account", func() { + m.Combo("").Get(user_setting.Account).Post(web.Bind(forms.ChangePasswordForm{}), user_setting.AccountPost) + m.Post("/email", web.Bind(forms.AddEmailForm{}), user_setting.EmailPost) + m.Post("/email/delete", user_setting.DeleteEmail) + m.Post("/delete", user_setting.DeleteAccount) + }) + m.Group("/appearance", func() { + m.Get("", user_setting.Appearance) + m.Post("/language", web.Bind(forms.UpdateLanguageForm{}), user_setting.UpdateUserLang) + m.Post("/hints", web.Bind(forms.UpdateHintsForm{}), user_setting.UpdateUserHints) + m.Post("/hidden_comments", user_setting.UpdateUserHiddenComments) + m.Post("/theme", web.Bind(forms.UpdateThemeForm{}), user_setting.UpdateUIThemePost) + }) + m.Group("/security", func() { + m.Get("", security.Security) + m.Group("/two_factor", func() { + m.Post("/regenerate_scratch", security.RegenerateScratchTwoFactor) + m.Post("/disable", security.DisableTwoFactor) + m.Get("/enroll", security.EnrollTwoFactor) + m.Post("/enroll", web.Bind(forms.TwoFactorAuthForm{}), security.EnrollTwoFactorPost) + }) + m.Group("/webauthn", func() { + m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister) + m.Post("/register", security.WebauthnRegisterPost) + m.Post("/delete", web.Bind(forms.WebauthnDeleteForm{}), security.WebauthnDelete) + }) + m.Group("/openid", func() { + m.Post("", web.Bind(forms.AddOpenIDForm{}), security.OpenIDPost) + m.Post("/delete", security.DeleteOpenID) + m.Post("/toggle_visibility", security.ToggleOpenIDVisibility) + }, openIDSignInEnabled) + m.Post("/account_link", linkAccountEnabled, security.DeleteAccountLink) + }) + + m.Group("/applications", func() { + // oauth2 applications + m.Group("/oauth2", func() { + m.Get("/{id}", user_setting.OAuth2ApplicationShow) + m.Post("/{id}", web.Bind(forms.EditOAuth2ApplicationForm{}), user_setting.OAuthApplicationsEdit) + m.Post("/{id}/regenerate_secret", user_setting.OAuthApplicationsRegenerateSecret) + m.Post("", web.Bind(forms.EditOAuth2ApplicationForm{}), user_setting.OAuthApplicationsPost) + m.Post("/{id}/delete", user_setting.DeleteOAuth2Application) + m.Post("/{id}/revoke/{grantId}", user_setting.RevokeOAuth2Grant) + }, oauth2Enabled) + + // access token applications + m.Combo("").Get(user_setting.Applications). + Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost) + m.Post("/delete", user_setting.DeleteApplication) + }) + + m.Combo("/keys").Get(user_setting.Keys). + Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost) + m.Post("/keys/delete", user_setting.DeleteKey) + m.Group("/packages", func() { + m.Get("", user_setting.Packages) + m.Group("/rules", func() { + m.Group("/add", func() { + m.Get("", user_setting.PackagesRuleAdd) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), user_setting.PackagesRuleAddPost) + }) + m.Group("/{id}", func() { + m.Get("", user_setting.PackagesRuleEdit) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), user_setting.PackagesRuleEditPost) + m.Get("/preview", user_setting.PackagesRulePreview) + }) + }) + m.Group("/cargo", func() { + m.Post("/initialize", user_setting.InitializeCargoIndex) + m.Post("/rebuild", user_setting.RebuildCargoIndex) + }) + m.Post("/chef/regenerate_keypair", user_setting.RegenerateChefKeyPair) + }, packagesEnabled) + + m.Group("/actions", func() { + m.Get("", user_setting.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsSecretsRoutes() + addSettingsVariablesRoutes() + }, actions.MustEnableActions) + + m.Get("/organization", user_setting.Organization) + m.Get("/repos", user_setting.Repos) + m.Post("/repos/unadopted", user_setting.AdoptOrDeleteRepository) + + m.Group("/hooks", func() { + m.Get("", user_setting.Webhooks) + m.Post("/delete", user_setting.DeleteWebhook) + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/blocked_users", func() { + m.Get("", user_setting.BlockedUsers) + m.Post("/unblock", user_setting.UnblockUser) + }) + }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) + + m.Group("/user", func() { + m.Get("/activate", auth.Activate) + m.Post("/activate", auth.ActivatePost) + m.Any("/activate_email", auth.ActivateEmail) + m.Get("/avatar/{username}/{size}", user.AvatarByUserName) + m.Get("/recover_account", auth.ResetPasswd) + m.Post("/recover_account", auth.ResetPasswdPost) + m.Get("/forgot_password", auth.ForgotPasswd) + m.Post("/forgot_password", auth.ForgotPasswdPost) + m.Post("/logout", auth.SignOut) + m.Get("/task/{task}", reqSignIn, user.TaskStatus) + m.Get("/stopwatches", reqSignIn, user.GetStopwatches) + m.Get("/search", ignExploreSignIn, user.Search) + m.Group("/oauth2", func() { + m.Get("/{provider}", auth.SignInOAuth) + m.Get("/{provider}/callback", auth.SignInOAuthCallback) + }) + }) + // ***** END: User ***** + + m.Get("/avatar/{hash}", user.AvatarByEmailHash) + + adminReq := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true, AdminRequired: true}) + + // ***** START: Admin ***** + m.Group("/admin", func() { + m.Get("", admin.Dashboard) + m.Get("/system_status", admin.SystemStatus) + m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) + + if setting.Database.Type.IsMySQL() { + m.Get("/self_check", admin.SelfCheck) + } + + m.Group("/config", func() { + m.Get("", admin.Config) + m.Post("", admin.ChangeConfig) + m.Post("/test_mail", admin.SendTestMail) + m.Post("/test_cache", admin.TestCache) + m.Get("/settings", admin.ConfigSettings) + }) + + m.Group("/monitor", func() { + m.Get("/stats", admin.MonitorStats) + m.Get("/cron", admin.CronTasks) + m.Get("/stacktrace", admin.Stacktrace) + m.Post("/stacktrace/cancel/{pid}", admin.StacktraceCancel) + m.Get("/queue", admin.Queues) + m.Group("/queue/{qid}", func() { + m.Get("", admin.QueueManage) + m.Post("/set", admin.QueueSet) + m.Post("/remove-all-items", admin.QueueRemoveAllItems) + }) + m.Get("/diagnosis", admin.MonitorDiagnosis) + }) + + m.Group("/users", func() { + m.Get("", admin.Users) + m.Combo("/new").Get(admin.NewUser).Post(web.Bind(forms.AdminCreateUserForm{}), admin.NewUserPost) + m.Get("/{userid}", admin.ViewUser) + m.Combo("/{userid}/edit").Get(admin.EditUser).Post(web.Bind(forms.AdminEditUserForm{}), admin.EditUserPost) + m.Post("/{userid}/delete", admin.DeleteUser) + m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost) + m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) + }) + + m.Group("/emails", func() { + m.Get("", admin.Emails) + m.Post("/activate", admin.ActivateEmail) + m.Post("/delete", admin.DeleteEmail) + }) + + 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("/packages", func() { + m.Get("", admin.Packages) + m.Post("/delete", admin.DeletePackageVersion) + m.Post("/cleanup", admin.CleanupExpiredData) + }, packagesEnabled) + + m.Group("/hooks", func() { + m.Get("", admin.DefaultOrSystemWebhooks) + m.Post("/delete", admin.DeleteDefaultOrSystemWebhook) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/{configType:default-hooks|system-hooks}", func() { + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + }) + + m.Group("/auths", func() { + m.Get("", admin.Authentications) + m.Combo("/new").Get(admin.NewAuthSource).Post(web.Bind(forms.AuthenticationForm{}), admin.NewAuthSourcePost) + m.Combo("/{authid}").Get(admin.EditAuthSource). + Post(web.Bind(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) + }) + + m.Group("/applications", func() { + m.Get("", admin.Applications) + m.Post("/oauth2", web.Bind(forms.EditOAuth2ApplicationForm{}), admin.ApplicationsPost) + m.Group("/oauth2/{id}", func() { + m.Combo("").Get(admin.EditApplication).Post(web.Bind(forms.EditOAuth2ApplicationForm{}), admin.EditApplicationPost) + m.Post("/regenerate_secret", admin.ApplicationsRegenerateSecret) + m.Post("/delete", admin.DeleteApplication) + }) + }, oauth2Enabled) + + m.Group("/actions", func() { + m.Get("", admin.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsVariablesRoutes() + }) + }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) + // ***** END: Admin ***** + + m.Group("", func() { + m.Get("/{username}", user.UsernameSubRoute) + m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment) + }, ignSignIn) + + m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.Action) + + reqRepoAdmin := context.RequireRepoAdmin() + reqRepoCodeWriter := context.RequireRepoWriter(unit.TypeCode) + canEnableEditor := context.CanEnableEditor() + reqRepoCodeReader := context.RequireRepoReader(unit.TypeCode) + reqRepoReleaseWriter := context.RequireRepoWriter(unit.TypeReleases) + reqRepoReleaseReader := context.RequireRepoReader(unit.TypeReleases) + reqRepoWikiWriter := context.RequireRepoWriter(unit.TypeWiki) + reqRepoIssueReader := context.RequireRepoReader(unit.TypeIssues) + reqRepoPullsReader := context.RequireRepoReader(unit.TypePullRequests) + reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(unit.TypeIssues, unit.TypePullRequests) + reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(unit.TypeIssues, unit.TypePullRequests) + reqRepoProjectsReader := context.RequireRepoReader(unit.TypeProjects) + reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects) + reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions) + reqRepoActionsWriter := context.RequireRepoWriter(unit.TypeActions) + + reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) { + return func(ctx *context.Context) { + if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() { + ctx.NotFound("", nil) + } + } + } + + individualPermsChecker := func(ctx *context.Context) { + // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. + if ctx.ContextUser.IsIndividual() { + switch { + case ctx.ContextUser.Visibility == structs.VisibleTypePrivate: + if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) { + ctx.NotFound("Visit Project", nil) + return + } + case ctx.ContextUser.Visibility == structs.VisibleTypeLimited: + if ctx.Doer == nil { + ctx.NotFound("Visit Project", nil) + return + } + } + } + } + + // ***** START: Organization ***** + m.Group("/org", func() { + m.Group("/{org}", func() { + m.Get("/members", org.Members) + }, context.OrgAssignment()) + }, ignSignIn) + + m.Group("/org", func() { + m.Group("", func() { + m.Get("/create", org.Create) + m.Post("/create", web.Bind(forms.CreateOrgForm{}), org.CreatePost) + }) + + m.Group("/invite/{token}", func() { + m.Get("", org.TeamInvite) + m.Post("", org.TeamInvitePost) + }) + + 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.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)) + + // require admin permission + m.Group("/{org}", func() { + m.Get("/teams/-/search", org.SearchTeam) + }, context.OrgAssignment(true, false, false, true)) + + // require owner permission + m.Group("/{org}", func() { + m.Get("/teams/new", org.NewTeam) + m.Post("/teams/new", web.Bind(forms.CreateTeamForm{}), org.NewTeamPost) + m.Get("/teams/{team}/edit", org.EditTeam) + m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost) + m.Post("/teams/{team}/delete", org.DeleteTeam) + + m.Group("/settings", func() { + m.Combo("").Get(org.Settings). + Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost) + m.Post("/avatar", web.Bind(forms.AvatarForm{}), org.SettingsAvatar) + m.Post("/avatar/delete", org.SettingsDeleteAvatar) + m.Group("/applications", func() { + m.Get("", org.Applications) + m.Post("/oauth2", web.Bind(forms.EditOAuth2ApplicationForm{}), org.OAuthApplicationsPost) + m.Group("/oauth2/{id}", func() { + m.Combo("").Get(org.OAuth2ApplicationShow).Post(web.Bind(forms.EditOAuth2ApplicationForm{}), org.OAuth2ApplicationEdit) + m.Post("/regenerate_secret", org.OAuthApplicationsRegenerateSecret) + m.Post("/delete", org.DeleteOAuth2Application) + }) + }, oauth2Enabled) + + m.Group("/hooks", func() { + m.Get("", org.Webhooks) + m.Post("/delete", org.DeleteWebhook) + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/labels", func() { + m.Get("", org.RetrieveLabels, org.Labels) + m.Post("/new", web.Bind(forms.CreateLabelForm{}), org.NewLabel) + m.Post("/edit", web.Bind(forms.CreateLabelForm{}), org.UpdateLabel) + m.Post("/delete", org.DeleteLabel) + m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), org.InitializeLabels) + }) + + m.Group("/actions", func() { + m.Get("", org_setting.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsSecretsRoutes() + addSettingsVariablesRoutes() + }, actions.MustEnableActions) + + m.Methods("GET,POST", "/delete", org.SettingsDelete) + + m.Group("/blocked_users", func() { + m.Get("", org_setting.BlockedUsers) + m.Post("/block", org_setting.BlockedUsersBlock) + m.Post("/unblock", org_setting.BlockedUsersUnblock) + }) + + m.Group("/packages", func() { + m.Get("", org.Packages) + m.Group("/rules", func() { + m.Group("/add", func() { + m.Get("", org.PackagesRuleAdd) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), org.PackagesRuleAddPost) + }) + m.Group("/{id}", func() { + m.Get("", org.PackagesRuleEdit) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), org.PackagesRuleEditPost) + m.Get("/preview", org.PackagesRulePreview) + }) + }) + m.Group("/cargo", func() { + m.Post("/initialize", org.InitializeCargoIndex) + m.Post("/rebuild", org.RebuildCargoIndex) + }) + }, packagesEnabled) + }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) + }, context.OrgAssignment(true, true)) + }, reqSignIn) + // ***** END: Organization ***** + + // ***** START: Repository ***** + m.Group("/repo", func() { + m.Get("/create", repo.Create) + m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost) + m.Get("/migrate", repo.Migrate) + m.Post("/migrate", web.Bind(forms.MigrateRepoForm{}), repo.MigratePost) + if !setting.Repository.DisableForks { + m.Get("/fork/{repoid}", context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader, repo.ForkByID) + } + m.Get("/search", repo.SearchRepo) + }, reqSignIn) + + m.Group("/{username}/-", func() { + if setting.Packages.Enabled { + m.Group("/packages", func() { + m.Get("", user.ListPackages) + m.Group("/{type}/{name}", func() { + m.Get("", user.RedirectToLastVersion) + m.Get("/versions", user.ListPackageVersions) + m.Group("/{version}", func() { + m.Get("", user.ViewPackageVersion) + m.Get("/files/{fileid}", user.DownloadPackageFile) + m.Group("/settings", func() { + m.Get("", user.PackageSettings) + m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + }) + }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) + } + + m.Group("/projects", func() { + m.Group("", func() { + m.Get("", org.Projects) + m.Get("/{id}", org.ViewProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("", func() { //nolint:dupl + m.Get("/new", org.RenderNewProject) + m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) + m.Group("/{id}", func() { + m.Post("", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost) + m.Post("/move", project.MoveColumns) + m.Post("/delete", org.DeleteProject) + + m.Get("/edit", org.RenderEditProject) + m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) + m.Post("/{action:open|close}", org.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn) + m.Delete("", org.DeleteProjectColumn) + m.Post("/default", org.SetDefaultProjectColumn) + + m.Post("/move", org.MoveIssues) + }) + }) + }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + + m.Group("", func() { + m.Get("/code", user.CodeSearch) + }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false), individualPermsChecker) + }, ignSignIn, context.UserAssignmentWeb(), context.OrgAssignment()) // for "/{username}/-" (packages, projects, code) + + m.Group("/{username}/{reponame}", func() { + m.Group("/settings", func() { + m.Group("", func() { + m.Combo("").Get(repo_setting.Settings). + Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost) + }, repo_setting.SettingsCtxData) + m.Combo("/units").Get(repo_setting.Units). + Post(web.Bind(forms.RepoUnitSettingForm{}), repo_setting.UnitsPost) + m.Post("/avatar", web.Bind(forms.AvatarForm{}), repo_setting.SettingsAvatar) + m.Post("/avatar/delete", repo_setting.SettingsDeleteAvatar) + + m.Group("/collaboration", func() { + m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost) + m.Post("/access_mode", repo_setting.ChangeCollaborationAccessMode) + m.Post("/delete", repo_setting.DeleteCollaboration) + m.Group("/team", func() { + m.Post("", repo_setting.AddTeamPost) + m.Post("/delete", repo_setting.DeleteTeam) + }) + }) + + m.Group("/branches", func() { + m.Post("/", repo_setting.SetDefaultBranchPost) + }, repo.MustBeNotEmpty) + + m.Group("/branches", func() { + m.Get("/", repo_setting.ProtectedBranchRules) + m.Combo("/edit").Get(repo_setting.SettingsProtectedBranch). + Post(web.Bind(forms.ProtectBranchForm{}), context.RepoMustNotBeArchived(), repo_setting.SettingsProtectedBranchPost) + m.Post("/{id}/delete", repo_setting.DeleteProtectedBranchRulePost) + }, repo.MustBeNotEmpty) + + m.Post("/rename_branch", web.Bind(forms.RenameBranchForm{}), context.RepoMustNotBeArchived(), repo_setting.RenameBranchPost) + + m.Group("/tags", func() { + m.Get("", repo_setting.ProtectedTags) + m.Post("", web.Bind(forms.ProtectTagForm{}), context.RepoMustNotBeArchived(), repo_setting.NewProtectedTagPost) + m.Post("/delete", context.RepoMustNotBeArchived(), repo_setting.DeleteProtectedTagPost) + m.Get("/{id}", repo_setting.EditProtectedTag) + m.Post("/{id}", web.Bind(forms.ProtectTagForm{}), context.RepoMustNotBeArchived(), repo_setting.EditProtectedTagPost) + }) + + m.Group("/hooks/git", func() { + m.Get("", repo_setting.GitHooks) + m.Combo("/{name}").Get(repo_setting.GitHooksEdit). + Post(repo_setting.GitHooksEditPost) + }, context.GitHookService()) + + m.Group("/hooks", func() { + m.Get("", repo_setting.WebhookList) + m.Post("/delete", repo_setting.WebhookDelete) + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/test", repo_setting.WebhookTest) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/keys", func() { + m.Combo("").Get(repo_setting.DeployKeys). + Post(web.Bind(forms.AddKeyForm{}), repo_setting.DeployKeysPost) + m.Post("/delete", repo_setting.DeleteDeployKey) + }) + + m.Group("/lfs", func() { + m.Get("/", repo_setting.LFSFiles) + m.Get("/show/{oid}", repo_setting.LFSFileGet) + m.Post("/delete/{oid}", repo_setting.LFSDelete) + m.Get("/pointers", repo_setting.LFSPointerFiles) + m.Post("/pointers/associate", repo_setting.LFSAutoAssociate) + m.Get("/find", repo_setting.LFSFileFind) + m.Group("/locks", func() { + m.Get("/", repo_setting.LFSLocks) + m.Post("/", repo_setting.LFSLockFile) + m.Post("/{lid}/unlock", repo_setting.LFSUnlock) + }) + }) + m.Group("/actions", func() { + m.Get("", repo_setting.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsSecretsRoutes() + addSettingsVariablesRoutes() + }, actions.MustEnableActions) + // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed + m.Group("/migrate", func() { + m.Post("/retry", repo.MigrateRetryPost) + m.Post("/cancel", repo.MigrateCancelPost) + }) + }, ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer)) + }, reqSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoAdmin, context.RepoRef()) + + m.Group("/{username}/{reponame}/action", func() { + m.Post("/watch", repo.ActionWatch(true)) + m.Post("/unwatch", repo.ActionWatch(false)) + m.Post("/accept_transfer", repo.ActionTransfer(true)) + m.Post("/reject_transfer", repo.ActionTransfer(false)) + if !setting.Repository.DisableStars { + m.Post("/star", repo.ActionStar(true)) + m.Post("/unstar", repo.ActionStar(false)) + } + }, reqSignIn, context.RepoAssignment, context.UnitTypes()) + + // Grouping for those endpoints not requiring authentication (but should respect ignSignIn) + m.Group("/{username}/{reponame}", func() { + m.Group("/milestone", func() { + m.Get("/{id}", repo.MilestoneIssuesAndPulls) + }, reqRepoIssuesOrPullsReader, context.RepoRef()) + m.Get("/find/*", repo.FindFiles) + m.Group("/tree-list", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.TreeList) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.TreeList) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.TreeList) + }) + m.Get("/compare", repo.MustBeNotEmpty, reqRepoCodeReader, repo.SetEditorconfigIfExists, ignSignIn, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff) + m.Combo("/compare/*", repo.MustBeNotEmpty, reqRepoCodeReader, repo.SetEditorconfigIfExists). + Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff). + Post(reqSignIn, context.RepoMustNotBeArchived(), reqRepoPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost) + m.Group("/{type:issues|pulls}", func() { + m.Group("/{index}", func() { + m.Get("/info", repo.GetIssueInfo) + }) + }) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication + + // Grouping for those endpoints that do require authentication + m.Group("/{username}/{reponame}", func() { + if !setting.Repository.DisableForks { + m.Combo("/fork", reqRepoCodeReader).Get(repo.Fork). + Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost) + } + m.Group("/issues", func() { + m.Group("/new", func() { + m.Combo("").Get(context.RepoRef(), repo.NewIssue). + Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost) + m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) + }) + m.Get("/search", repo.ListIssues) + }, 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("/{type:issues|pulls}", func() { + m.Group("/{index}", func() { + m.Post("/title", repo.UpdateIssueTitle) + m.Post("/content", repo.UpdateIssueContent) + m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline) + m.Post("/watch", repo.IssueWatch) + m.Post("/ref", repo.UpdateIssueRef) + m.Post("/pin", reqRepoAdmin, repo.IssuePinOrUnpin) + m.Post("/viewed-files", repo.UpdateViewedFiles) + m.Group("/dependency", func() { + m.Post("/add", repo.AddDependency) + m.Post("/delete", repo.RemoveDependency) + }) + m.Combo("/comments").Post(repo.MustAllowUserComment, web.Bind(forms.CreateCommentForm{}), repo.NewComment) + m.Group("/times", func() { + m.Post("/add", web.Bind(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}", web.Bind(forms.ReactionForm{}), repo.ChangeIssueReaction) + m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue) + m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue) + m.Post("/delete", reqRepoAdmin, repo.DeleteIssue) + }, context.RepoMustNotBeArchived()) + m.Group("/{index}", func() { + m.Get("/attachments", repo.GetIssueAttachments) + m.Get("/attachments/{uuid}", repo.GetAttachment) + }) + m.Group("/{index}", func() { + m.Post("/content-history/soft-delete", repo.SoftDeleteContentHistory) + }) + + m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) + m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) + m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject) + m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) + m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) + m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview) + m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) + m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) + m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.SetShowOutdatedComments, repo.UpdateResolveConversation) + m.Post("/attachments", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeAssetsAttachmentsIssues, context.QuotaTargetRepo), repo.UploadIssueAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) + m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin) + m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Post("", repo.UpdateCommentContent) + m.Post("/delete", repo.DeleteComment) + m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeCommentReaction) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Get("/attachments", repo.GetCommentAttachments) + }) + m.Post("/markup", web.Bind(structs.MarkupOption{}), misc.Markup) + m.Group("/labels", func() { + m.Post("/new", web.Bind(forms.CreateLabelForm{}), repo.NewLabel) + m.Post("/edit", web.Bind(forms.CreateLabelForm{}), repo.UpdateLabel) + m.Post("/delete", repo.DeleteLabel) + m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), repo.InitializeLabels) + }, context.RepoMustNotBeArchived(), reqRepoIssuesOrPullsWriter, context.RepoRef()) + m.Group("/milestones", func() { + m.Combo("/new").Get(repo.NewMilestone). + Post(web.Bind(forms.CreateMilestoneForm{}), repo.NewMilestonePost) + m.Get("/{id}/edit", repo.EditMilestone) + m.Post("/{id}/edit", web.Bind(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(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) + m.Combo("/_new/*").Get(repo.NewFile). + Post(web.Bind(forms.EditRepoFileForm{}), repo.NewFilePost) + m.Post("/_preview/*", web.Bind(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost) + m.Combo("/_delete/*").Get(repo.DeleteFile). + Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) + m.Combo("/_upload/*", repo.MustBeAbleToUpload). + Get(repo.UploadFile). + Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) + m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). + Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) + m.Combo("/_cherrypick/{sha:([a-f0-9]{4,64})}/*").Get(repo.CherryPick). + Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) + }, repo.MustBeEditable, repo.CommonEditorData, context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) + m.Group("", func() { + m.Post("/upload-file", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.UploadFileToServer) + m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) + }, repo.MustBeEditable, repo.MustBeAbleToUpload) + }, context.RepoRef(), canEnableEditor, context.RepoMustNotBeArchived()) + + 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) + }, web.Bind(forms.NewBranchForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) + m.Post("/delete", repo.DeleteBranchPost) + m.Post("/restore", repo.RestoreBranchPost) + }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty) + }, reqSignIn, context.RepoAssignment, context.UnitTypes()) + + // Tags + m.Group("/{username}/{reponame}", func() { + m.Group("/tags", func() { + m.Get("", repo.TagsList) + m.Get("/list", repo.GetTagList) + m.Get(".rss", feedEnabled, repo.TagsListFeedRSS) + m.Get(".atom", feedEnabled, repo.TagsListFeedAtom) + }, ctxDataSet("EnableFeed", setting.Other.EnableFeed), + repo.MustBeNotEmpty, reqRepoCodeReader, context.RepoRefByType(context.RepoRefTag, true)) + m.Post("/tags/delete", repo.DeleteTag, reqSignIn, + repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoCodeWriter, context.RepoRef()) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + + // Releases + m.Group("/{username}/{reponame}", func() { + m.Group("/releases", func() { + m.Get("/", repo.Releases) + m.Get("/tag/*", repo.SingleRelease) + m.Get("/latest", repo.LatestRelease) + m.Get(".rss", feedEnabled, repo.ReleasesFeedRSS) + m.Get(".atom", feedEnabled, repo.ReleasesFeedAtom) + }, ctxDataSet("EnableFeed", setting.Other.EnableFeed), + repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefTag, true)) + m.Get("/releases/attachments/{uuid}", repo.MustBeNotEmpty, repo.GetAttachment) + m.Get("/releases/download/{vTag}/{fileName}", repo.MustBeNotEmpty, repo.RedirectDownload) + m.Group("/releases", func() { + m.Combo("/new", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)). + Get(repo.NewRelease). + Post(web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) + m.Post("/delete", repo.DeleteRelease) + m.Post("/attachments", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeAssetsAttachmentsReleases, context.QuotaTargetRepo), repo.UploadReleaseAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) + }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, context.RepoRef()) + m.Group("/releases", func() { + m.Get("/edit/*", repo.EditRelease) + m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost) + }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache, context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) + }, ignSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoReleaseReader) + + // to maintain compatibility with old attachments + m.Group("/{username}/{reponame}", func() { + m.Get("/attachments/{uuid}", repo.GetAttachment) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + + 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("/issues/posters", repo.IssuePosters) // it can't use {type:issues|pulls} because other routes like "/pulls/{index}" has higher priority + m.Get("/{type:issues|pulls}", repo.Issues) + m.Get("/{type:issues|pulls}/{index}", repo.ViewIssue) + m.Group("/{type:issues|pulls}/{index}/content-history", func() { + m.Get("/overview", repo.GetContentHistoryOverview) + m.Get("/list", repo.GetContentHistoryList) + m.Get("/detail", repo.GetContentHistoryDetail) + }) + m.Get("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels) + m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) + }, context.RepoRef()) + + if setting.Packages.Enabled { + m.Get("/packages", repo.Packages) + } + + if setting.Badges.Enabled { + m.Group("/badges", func() { + m.Get("/workflows/{workflow_name}/badge.svg", badges.GetWorkflowBadge) + m.Group("/issues", func() { + m.Get(".svg", badges.GetTotalIssuesBadge) + m.Get("/open.svg", badges.GetOpenIssuesBadge) + m.Get("/closed.svg", badges.GetClosedIssuesBadge) + }) + m.Group("/pulls", func() { + m.Get(".svg", badges.GetTotalPullsBadge) + m.Get("/open.svg", badges.GetOpenPullsBadge) + m.Get("/closed.svg", badges.GetClosedPullsBadge) + }) + if !setting.Repository.DisableStars { + m.Get("/stars.svg", badges.GetStarsBadge) + } + m.Get("/release.svg", badges.GetLatestReleaseBadge) + }) + } + + m.Group("/projects", func() { + m.Get("", repo.Projects) + m.Get("/{id}", repo.ViewProject) + m.Group("", func() { //nolint:dupl + m.Get("/new", repo.RenderNewProject) + m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) + m.Group("/{id}", func() { + m.Post("", web.Bind(forms.EditProjectColumnForm{}), repo.AddColumnToProjectPost) + m.Post("/move", project.MoveColumns) + m.Post("/delete", repo.DeleteProject) + + m.Get("/edit", repo.RenderEditProject) + m.Post("/edit", web.Bind(forms.CreateProjectForm{}), repo.EditProjectPost) + m.Post("/{action:open|close}", repo.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", web.Bind(forms.EditProjectColumnForm{}), repo.EditProjectColumn) + m.Delete("", repo.DeleteProjectColumn) + m.Post("/default", repo.SetDefaultProjectColumn) + + m.Post("/move", repo.MoveIssues) + }) + }) + }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) + }, reqRepoProjectsReader, repo.MustEnableProjects) + + m.Group("/actions", func() { + m.Get("", actions.List) + m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) + m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) + m.Post("/manual", reqRepoAdmin, actions.ManualRunWorkflow) + + m.Group("/runs", func() { + m.Get("/latest", actions.ViewLatest) + m.Group("/{run}", func() { + m.Combo(""). + Get(actions.View). + Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + m.Group("/jobs/{job}", func() { + m.Combo(""). + Get(actions.View). + Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) + m.Get("/logs", actions.Logs) + }) + m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) + m.Post("/approve", reqRepoActionsWriter, actions.Approve) + m.Get("/artifacts", actions.ArtifactsView) + m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) + m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView) + m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) + }) + }) + + m.Group("/workflows/{workflow_name}", func() { + m.Get("/badge.svg", badges.GetWorkflowBadge) + m.Get("/runs/latest", actions.ViewLatestWorkflowRun) + }) + }, reqRepoActionsReader, actions.MustEnableActions) + + m.Group("/wiki", func() { + m.Combo("/"). + Get(repo.Wiki). + Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.WikiPost) + m.Combo("/*"). + Get(repo.Wiki). + Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.WikiPost) + m.Get("/commit/{sha:[a-f0-9]{4,64}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/commit/{sha:[a-f0-9]{4,64}}.{ext:patch|diff}", repo.RawDiff) + }, repo.MustEnableWiki, func(ctx *context.Context) { + ctx.Data["PageIsWiki"] = true + ctx.Data["CloneButtonOriginLink"] = ctx.Repo.Repository.WikiCloneLink() + }) + + m.Group("/wiki", func() { + m.Get("/search", repo.WikiSearchContent) + m.Get("/raw/*", repo.WikiRaw) + }, repo.MustEnableWiki) + + m.Group("/activity", func() { + m.Get("", repo.Activity) + m.Get("/{period}", repo.Activity) + m.Group("/contributors", func() { + m.Get("", repo.Contributors) + m.Get("/data", repo.ContributorsData) + }, repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + m.Group("/code-frequency", func() { + m.Get("", repo.CodeFrequency) + m.Get("/data", repo.CodeFrequencyData) + }, repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + m.Group("/recent-commits", func() { + m.Get("", repo.RecentCommits) + m.Get("/data", repo.RecentCommitsData) + }, repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + }, context.RepoRef(), context.RequireRepoReaderOr(unit.TypeCode, unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases)) + + m.Group("/activity_author_data", func() { + m.Get("", repo.ActivityAuthors) + m.Get("/{period}", repo.ActivityAuthors) + }, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + + m.Group("/archive", func() { + m.Get("/*", repo.Download) + m.Post("/*", repo.InitiateDownload) + }, repo.MustBeNotEmpty, dlSourceEnabled, reqRepoCodeReader) + + m.Group("/branches", func() { + m.Get("/list", repo.GetBranchesList) + m.Get("", repo.Branches) + }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) + + m.Group("/blob_excerpt", func() { + m.Get("/{sha}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.ExcerptBlob) + }, func(ctx *context.Context) gocontext.CancelFunc { + if ctx.FormBool("wiki") { + ctx.Data["PageIsWiki"] = true + repo.MustEnableWiki(ctx) + return nil + } + + reqRepoCodeReader(ctx) + if ctx.Written() { + return nil + } + cancel := context.RepoRef()(ctx) + if ctx.Written() { + return cancel + } + + repo.MustBeNotEmpty(ctx) + return cancel + }) + + m.Get("/pulls/posters", repo.PullPosters) + m.Group("/pulls/{index}", func() { + m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue) + m.Get(".diff", repo.DownloadPullDiff) + m.Get(".patch", repo.DownloadPullPatch) + m.Group("/commits", func() { + m.Get("", context.RepoRef(), repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits) + m.Get("/list", context.RepoRef(), repo.GetPullCommits) + m.Get("/{sha:[a-f0-9]{4,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForSingleCommit) + }) + m.Post("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.MergePullRequest) + m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest) + m.Post("/update", repo.UpdatePullRequest) + m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits) + m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest) + m.Group("/files", func() { + m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForAllCommitsOfPr) + m.Get("/{sha:[a-f0-9]{4,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesStartingFromCommit) + m.Get("/{shaFrom:[a-f0-9]{4,40}}..{shaTo:[a-f0-9]{4,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForRange) + m.Group("/reviews", func() { + m.Get("/new_comment", repo.RenderNewCodeCommentForm) + m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment) + m.Post("/submit", web.Bind(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("/render", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RenderFile) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RenderFile) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.RenderFile) + m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.RenderFile) + }, 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]{4,64})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/commit/{sha:([a-f0-9]{4,64})$}/load-branches-and-tags", repo.LoadBranchesAndTags) + m.Get("/cherry-pick/{sha:([a-f0-9]{4,64})$}", repo.SetEditorconfigIfExists, repo.CherryPick) + }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) + + m.Group("", func() { + m.Get("/rss/branch/*", feed.RenderBranchFeed("rss")) + m.Get("/atom/branch/*", feed.RenderBranchFeed("atom")) + }, repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), reqRepoCodeReader, feedEnabled) + + 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) + + if !setting.Repository.DisableForks { + m.Group("", func() { + m.Get("/forks", repo.Forks) + }, context.RepoRef(), reqRepoCodeReader) + } + m.Get("/commit/{sha:([a-f0-9]{4,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + + m.Post("/{username}/{reponame}/lastcommit/*", ignSignInAndCsrf, context.RepoAssignment, context.UnitTypes(), context.RepoRefByType(context.RepoRefCommit), reqRepoCodeReader, repo.LastCommit) + + m.Group("/{username}/{reponame}", func() { + if !setting.Repository.DisableStars { + m.Get("/stars", context.RepoRef(), repo.Stars) + } + m.Get("/watchers", context.RepoRef(), repo.Watchers) + m.Group("/search", func() { + m.Get("", context.RepoRef(), repo.Search) + if !setting.Indexer.RepoIndexerEnabled { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.Search) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.Search) + } + }, reqRepoCodeReader) + }, ignSignIn, context.RepoAssignment, 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) + + gitHTTPRouters(m) + }) + }) + + if setting.Repository.EnableFlags { + m.Group("/{username}/{reponame}/flags", func() { + m.Get("", repo_flags.Manage) + m.Post("", repo_flags.ManagePost) + }, adminReq, context.RepoAssignment, context.UnitTypes()) + } + // ***** END: Repository ***** + + m.Group("/notifications", func() { + m.Get("", user.Notifications) + m.Get("/subscriptions", user.NotificationSubscriptions) + m.Get("/watching", user.NotificationWatching) + m.Post("/status", user.NotificationStatusPost) + m.Post("/purge", user.NotificationPurgePost) + m.Get("/new", user.NewAvailable) + }, reqSignIn) + + if setting.API.EnableSwagger { + m.Get("/swagger.v1.json", SwaggerV1Json) + } + + if !setting.IsProd { + m.Any("/devtest", devtest.List) + m.Any("/devtest/fetch-action-test", devtest.FetchActionTest) + m.Any("/devtest/{sub}", devtest.Tmpl) + } + + m.NotFound(func(w http.ResponseWriter, req *http.Request) { + ctx := context.GetWebContext(req) + ctx.NotFound("", nil) + }) +} diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go new file mode 100644 index 0000000..1f3de70 --- /dev/null +++ b/routers/web/webfinger.go @@ -0,0 +1,167 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-webfinger-14#section-4.4 + +type webfingerJRD struct { + Subject string `json:"subject,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + Links []*webfingerLink `json:"links,omitempty"` +} + +type webfingerLink struct { + Rel string `json:"rel,omitempty"` + Type string `json:"type,omitempty"` + Href string `json:"href,omitempty"` + Titles map[string]string `json:"titles,omitempty"` + Properties map[string]any `json:"properties,omitempty"` +} + +// WebfingerQuery returns information about a resource +// https://datatracker.ietf.org/doc/html/rfc7565 +func WebfingerQuery(ctx *context.Context) { + appURL, _ := url.Parse(setting.AppURL) + + resource, err := url.Parse(ctx.FormTrim("resource")) + if err != nil { + ctx.Error(http.StatusBadRequest) + return + } + + var u *user_model.User + + switch resource.Scheme { + case "acct": + // allow only the current host + parts := strings.SplitN(resource.Opaque, "@", 2) + if len(parts) != 2 { + ctx.Error(http.StatusBadRequest) + return + } + if parts[1] != appURL.Host { + ctx.Error(http.StatusBadRequest) + return + } + + u, err = user_model.GetUserByName(ctx, parts[0]) + case "mailto": + u, err = user_model.GetUserByEmail(ctx, resource.Opaque) + if u != nil && u.KeepEmailPrivate { + err = user_model.ErrUserNotExist{} + } + case "https", "http": + if resource.Host != appURL.Host { + ctx.Error(http.StatusBadRequest) + return + } + + p := strings.Trim(resource.Path, "/") + if len(p) == 0 { + ctx.Error(http.StatusNotFound) + return + } + + parts := strings.Split(p, "/") + + switch len(parts) { + case 1: // user + u, err = user_model.GetUserByName(ctx, parts[0]) + case 2: // repository + ctx.Error(http.StatusNotFound) + return + + case 3: + switch parts[2] { + case "issues": + ctx.Error(http.StatusNotFound) + return + + case "pulls": + ctx.Error(http.StatusNotFound) + return + + case "projects": + ctx.Error(http.StatusNotFound) + return + + default: + ctx.Error(http.StatusNotFound) + return + } + + default: + ctx.Error(http.StatusNotFound) + return + } + + default: + ctx.Error(http.StatusBadRequest) + return + } + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + log.Error("Error getting user: %s Error: %v", resource.Opaque, err) + ctx.Error(http.StatusInternalServerError) + } + return + } + + if !user_model.IsUserVisibleToViewer(ctx, u, ctx.Doer) { + ctx.Error(http.StatusNotFound) + return + } + + aliases := []string{ + u.HTMLURL(), + appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(u.ID), + } + if !u.KeepEmailPrivate { + aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email)) + } + + links := []*webfingerLink{ + { + Rel: "http://webfinger.net/rel/profile-page", + Type: "text/html", + Href: u.HTMLURL(), + }, + { + Rel: "http://webfinger.net/rel/avatar", + Href: u.AvatarLink(ctx), + }, + { + Rel: "self", + Type: "application/activity+json", + Href: appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(u.ID), + }, + { + Rel: "http://openid.net/specs/connect/1.0/issuer", + Href: appURL.String(), + }, + } + + ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*") + ctx.JSON(http.StatusOK, &webfingerJRD{ + Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host), + Aliases: aliases, + Links: links, + }) + ctx.Resp.Header().Set("Content-Type", "application/jrd+json") +} |