初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// 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"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/updatechecker"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/cron"
|
||||
"gitea.dev/services/forms"
|
||||
release_service "gitea.dev/services/release"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
tplDashboard templates.TplName = "admin/dashboard"
|
||||
tplSystemStatus templates.TplName = "admin/system_status"
|
||||
tplSelfCheck templates.TplName = "admin/self_check"
|
||||
tplCron templates.TplName = "admin/cron"
|
||||
tplQueue templates.TplName = "admin/queue"
|
||||
tplPerfTrace templates.TplName = "admin/perftrace"
|
||||
tplStacktrace templates.TplName = "admin/stacktrace"
|
||||
tplQueueManage templates.TplName = "admin/queue_manage"
|
||||
tplStats templates.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 prepareStartupProblemsAlert(ctx *context.Context) {
|
||||
if len(setting.StartupProblems) > 0 {
|
||||
content := setting.StartupProblems[0]
|
||||
if len(setting.StartupProblems) > 1 {
|
||||
content += fmt.Sprintf(" (and %d more)", len(setting.StartupProblems)-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
|
||||
prepareStartupProblemsAlert(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
|
||||
|
||||
ctx.Data["StartupProblems"] = setting.StartupProblems
|
||||
if len(setting.StartupProblems) == 0 && !setting.IsProd {
|
||||
if time.Now().Unix()%2 == 0 {
|
||||
ctx.Data["StartupProblems"] = []string{"This is a test warning message in dev mode"}
|
||||
}
|
||||
}
|
||||
|
||||
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 SelfCheckPost(ctx *context.Context) {
|
||||
var problems []string
|
||||
frontendAppURL := ctx.FormString("location_origin") + setting.AppSubURL + "/"
|
||||
ctxAppURL := httplib.GuessCurrentAppURL(ctx)
|
||||
if !strings.HasPrefix(ctxAppURL, frontendAppURL) {
|
||||
problems = append(problems, ctx.Locale.TrString("admin.self_check.location_origin_mismatch", frontendAppURL, ctxAppURL))
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{"problems": problems})
|
||||
}
|
||||
|
||||
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
|
||||
bs, err := json.Marshal(activities_model.GetStatistic(ctx).Counter)
|
||||
if err != nil {
|
||||
ctx.ServerError("MonitorStats", err)
|
||||
return
|
||||
}
|
||||
statsCounter := map[string]any{}
|
||||
err = json.Unmarshal(bs, &statsCounter)
|
||||
if err != nil {
|
||||
ctx.ServerError("MonitorStats", err)
|
||||
return
|
||||
}
|
||||
statsKeys := make([]string, 0, len(statsCounter))
|
||||
for k := range statsCounter {
|
||||
if statsCounter[k] == nil {
|
||||
continue
|
||||
}
|
||||
statsKeys = append(statsKeys, k)
|
||||
}
|
||||
sort.Strings(statsKeys)
|
||||
ctx.Data["StatsKeys"] = statsKeys
|
||||
ctx.Data["StatsCounter"] = statsCounter
|
||||
ctx.HTML(http.StatusOK, tplStats)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSelfCheckPost(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.PublicURLDetection)()
|
||||
defer test.MockVariableValue(&setting.AppURL, "http://config/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
|
||||
data := struct {
|
||||
Problems []string `json:"problems"`
|
||||
}{}
|
||||
|
||||
setting.PublicURLDetection = setting.PublicURLLegacy
|
||||
ctx, resp := contexttest.MockContext(t, "GET http://host/sub/admin/self_check?location_origin=http://frontend")
|
||||
SelfCheckPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &data))
|
||||
assert.Equal(t, []string{
|
||||
ctx.Locale.TrString("admin.self_check.location_origin_mismatch", "http://frontend/sub/", "http://config/sub/"),
|
||||
}, data.Problems)
|
||||
|
||||
setting.PublicURLDetection = setting.PublicURLAuto
|
||||
ctx, resp = contexttest.MockContext(t, "GET http://host/sub/admin/self_check?location_origin=http://frontend")
|
||||
SelfCheckPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &data))
|
||||
assert.Equal(t, []string{
|
||||
ctx.Locale.TrString("admin.self_check.location_origin_mismatch", "http://frontend/sub/", "http://host/sub/"),
|
||||
}, data.Problems)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
user_setting "gitea.dev/routers/web/user/setting"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
var (
|
||||
tplSettingsApplications templates.TplName = "admin/applications/list"
|
||||
tplSettingsOauth2ApplicationEdit templates.TplName = "admin/applications/oauth2_edit"
|
||||
)
|
||||
|
||||
func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers {
|
||||
return &user_setting.OAuth2CommonHandlers{
|
||||
OwnerID: 0,
|
||||
BasePathList: setting.AppSubURL + "/-/admin/applications",
|
||||
BasePathEditPrefix: setting.AppSubURL + "/-/admin/applications/oauth2",
|
||||
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_title")
|
||||
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
|
||||
@@ -0,0 +1,467 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/auth/pam"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/ldap"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
pam_service "gitea.dev/services/auth/source/pam"
|
||||
"gitea.dev/services/auth/source/smtp"
|
||||
"gitea.dev/services/auth/source/sspi"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplAuths templates.TplName = "admin/auth/list"
|
||||
tplAuthNew templates.TplName = "admin/auth/new"
|
||||
tplAuthEdit templates.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)
|
||||
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
|
||||
if len(oauth2providers) > 0 {
|
||||
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,
|
||||
AttributeUsername: form.AttributeUsername,
|
||||
AttributeName: form.AttributeName,
|
||||
AttributeSurname: form.AttributeSurname,
|
||||
AttributeMail: form.AttributeMail,
|
||||
AttributesInBind: form.AttributesInBind,
|
||||
AttributeSSHPublicKey: form.AttributeSSHPublicKey,
|
||||
AttributeAvatar: form.AttributeAvatar,
|
||||
SSHKeysAreVerified: form.SSHKeysAreVerified,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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.SplitSeq(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,
|
||||
GroupClaimName: form.Oauth2GroupClaimName,
|
||||
RestrictedGroup: form.Oauth2RestrictedGroup,
|
||||
AdminGroup: form.Oauth2AdminGroup,
|
||||
GroupTeamMap: form.Oauth2GroupTeamMap,
|
||||
GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval,
|
||||
|
||||
SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName,
|
||||
FullNameClaimName: form.Oauth2FullNameClaimName,
|
||||
ExternalIDClaim: form.OpenIDConnectExternalIDClaim,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 auth.Config
|
||||
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,
|
||||
}
|
||||
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.RenderWithErrDeprecated(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthNew, form)
|
||||
return
|
||||
}
|
||||
}
|
||||
case auth.SSPI:
|
||||
var err error
|
||||
config, err = parseSSPIConfig(ctx, form)
|
||||
if err != nil {
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.HTTPError(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,
|
||||
TwoFactorPolicy: form.TwoFactorPolicy,
|
||||
Cfg: config,
|
||||
}); err != nil {
|
||||
if auth.IsErrSourceAlreadyExist(err) {
|
||||
ctx.Data["Err_Name"] = true
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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)
|
||||
ctx.Data["OAuth2Providers"] = oauth2providers
|
||||
|
||||
source, err := auth.GetSourceByID(ctx, ctx.PathParamInt64("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)
|
||||
ctx.Data["OAuth2Providers"] = oauth2providers
|
||||
|
||||
source, err := auth.GetSourceByID(ctx, ctx.PathParamInt64("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 auth.Config
|
||||
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.RenderWithErrDeprecated(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthEdit, form)
|
||||
return
|
||||
}
|
||||
}
|
||||
case auth.SSPI:
|
||||
config, err = parseSSPIConfig(ctx, form)
|
||||
if err != nil {
|
||||
ctx.RenderWithErrDeprecated(err.Error(), tplAuthEdit, form)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
source.Name = form.Name
|
||||
source.IsActive = form.IsActive
|
||||
source.IsSyncEnabled = form.IsSyncEnabled
|
||||
source.Cfg = config
|
||||
source.TwoFactorPolicy = form.TwoFactorPolicy
|
||||
if err := auth.UpdateSource(ctx, source); err != nil {
|
||||
if auth.IsErrSourceAlreadyExist(err) {
|
||||
ctx.Data["Err_Name"] = true
|
||||
ctx.RenderWithErrDeprecated(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(source.ID, 10))
|
||||
}
|
||||
|
||||
// DeleteAuthSource response for deleting an auth source
|
||||
func DeleteAuthSource(ctx *context.Context) {
|
||||
source, err := auth.GetSourceByID(ctx, ctx.PathParamInt64("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.PathParam("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")
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// Copyright 2026 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplBadges templates.TplName = "admin/badge/list"
|
||||
tplBadgeNew templates.TplName = "admin/badge/new"
|
||||
tplBadgeView templates.TplName = "admin/badge/view"
|
||||
tplBadgeEdit templates.TplName = "admin/badge/edit"
|
||||
tplBadgeUsers templates.TplName = "admin/badge/users"
|
||||
)
|
||||
|
||||
// BadgeSearchDefaultAdminSort is the default sort type for admin view
|
||||
const BadgeSearchDefaultAdminSort = "oldest"
|
||||
|
||||
// Badges show all the badges
|
||||
func Badges(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges")
|
||||
ctx.Data["PageIsAdminBadges"] = true
|
||||
|
||||
RenderBadgeSearch(ctx, &user_model.SearchBadgeOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: max(ctx.FormInt("page"), 1),
|
||||
PageSize: setting.UI.Admin.UserPagingNum,
|
||||
},
|
||||
}, tplBadges)
|
||||
}
|
||||
|
||||
// NewBadge render adding a new badge
|
||||
func NewBadge(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge")
|
||||
ctx.Data["PageIsAdminBadges"] = true
|
||||
|
||||
ctx.HTML(http.StatusOK, tplBadgeNew)
|
||||
}
|
||||
|
||||
// NewBadgePost response for adding a new badge
|
||||
func NewBadgePost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm)
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
b := &user_model.Badge{
|
||||
Slug: form.Slug,
|
||||
Description: form.Description,
|
||||
ImageURL: form.ImageURL,
|
||||
}
|
||||
|
||||
if err := user_model.CreateBadge(ctx, b); err != nil {
|
||||
if errors.Is(err, util.ErrAlreadyExist) {
|
||||
ctx.JSONError(ctx.Tr("admin.badges.slug_been_taken"))
|
||||
} else {
|
||||
ctx.ServerError("CreateBadge", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Badge created by admin (%s): %s", ctx.Doer.Name, b.Slug)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("admin.badges.new_success", b.Slug))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(b.Slug))
|
||||
}
|
||||
|
||||
func prepareBadgeInfo(ctx *context.Context) *user_model.Badge {
|
||||
b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/badges")
|
||||
} else {
|
||||
ctx.ServerError("GetBadge", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ctx.Data["Badge"] = b
|
||||
return b
|
||||
}
|
||||
|
||||
func ViewBadge(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges.details")
|
||||
ctx.Data["PageIsAdminBadges"] = true
|
||||
|
||||
prepareBadgeInfo(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
badge := ctx.Data["Badge"].(*user_model.Badge)
|
||||
opts := &user_model.GetBadgeUsersOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: setting.UI.Admin.UserPagingNum,
|
||||
},
|
||||
BadgeSlug: badge.Slug,
|
||||
}
|
||||
users, count, err := user_model.GetBadgeUsers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBadgeUsers", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Users"] = users
|
||||
ctx.Data["UsersTotal"] = int(count)
|
||||
|
||||
ctx.HTML(http.StatusOK, tplBadgeView)
|
||||
}
|
||||
|
||||
// EditBadge show editing badge page
|
||||
func EditBadge(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badge")
|
||||
ctx.Data["PageIsAdminBadges"] = true
|
||||
prepareBadgeInfo(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplBadgeEdit)
|
||||
}
|
||||
|
||||
// EditBadgePost response for editing badge
|
||||
func EditBadgePost(ctx *context.Context) {
|
||||
b := prepareBadgeInfo(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.AdminEditBadgeForm)
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
b.ImageURL = form.ImageURL
|
||||
b.Description = form.Description
|
||||
|
||||
if err := user_model.UpdateBadge(ctx, b); err != nil {
|
||||
ctx.ServerError("UpdateBadge", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Badge updated by admin (%s): %s", ctx.Doer.Name, b.Slug)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("admin.badges.update_success"))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")))
|
||||
}
|
||||
|
||||
// DeleteBadge response for deleting a badge
|
||||
func DeleteBadge(ctx *context.Context) {
|
||||
b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug"))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBadge", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = user_model.DeleteBadge(ctx, b); err != nil {
|
||||
ctx.ServerError("DeleteBadge", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Badge deleted by admin (%s): %s", ctx.Doer.Name, b.Slug)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("admin.badges.deletion_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/badges")
|
||||
}
|
||||
|
||||
func BadgeUsers(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges.users_with_badge", ctx.PathParam("badge_slug"))
|
||||
ctx.Data["PageIsAdminBadges"] = true
|
||||
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
|
||||
badge := &user_model.Badge{Slug: ctx.PathParam("badge_slug")}
|
||||
opts := &user_model.GetBadgeUsersOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: setting.UI.Admin.UserPagingNum,
|
||||
},
|
||||
BadgeSlug: badge.Slug,
|
||||
}
|
||||
users, count, err := user_model.GetBadgeUsers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBadgeUsers", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Users"] = users
|
||||
ctx.Data["Total"] = count
|
||||
ctx.Data["Page"] = context.NewPagination(count, setting.UI.Admin.UserPagingNum, page, 5)
|
||||
|
||||
ctx.HTML(http.StatusOK, tplBadgeUsers)
|
||||
}
|
||||
|
||||
// BadgeUsersPost response for actions for user badges
|
||||
func BadgeUsersPost(ctx *context.Context) {
|
||||
name := strings.ToLower(ctx.FormString("user"))
|
||||
|
||||
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 err = user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.Flash.Error(ctx.Tr("admin.badges.not_found"))
|
||||
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
|
||||
} else if errors.Is(err, util.ErrAlreadyExist) {
|
||||
ctx.Flash.Error(ctx.Tr("admin.badges.user_already_has"))
|
||||
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
|
||||
} else {
|
||||
ctx.ServerError("AddUserBadge", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("admin.badges.user_add_success"))
|
||||
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
|
||||
}
|
||||
|
||||
// DeleteBadgeUser delete a badge from a user
|
||||
func DeleteBadgeUser(ctx *context.Context) {
|
||||
badgeUsersURL := setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")) + "/users"
|
||||
|
||||
user, err := user_model.GetUserByID(ctx, ctx.FormInt64("id"))
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
|
||||
ctx.JSONRedirect(badgeUsersURL)
|
||||
return
|
||||
} else {
|
||||
ctx.ServerError("GetUserByID", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := user_model.RemoveUserBadge(ctx, user, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err == nil {
|
||||
ctx.Flash.Success(ctx.Tr("admin.badges.user_remove_success"))
|
||||
} else {
|
||||
ctx.ServerError("RemoveUserBadge", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(badgeUsersURL)
|
||||
}
|
||||
|
||||
func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName templates.TplName) {
|
||||
var (
|
||||
badges []*user_model.Badge
|
||||
count int64
|
||||
err error
|
||||
orderBy db.SearchOrderBy
|
||||
)
|
||||
|
||||
sortOrder := ctx.FormString("sort")
|
||||
if sortOrder == "" {
|
||||
sortOrder = BadgeSearchDefaultAdminSort
|
||||
}
|
||||
ctx.Data["SortType"] = sortOrder
|
||||
|
||||
switch sortOrder {
|
||||
case "newest":
|
||||
orderBy = "`badge`.id DESC"
|
||||
case "oldest":
|
||||
orderBy = "`badge`.id ASC"
|
||||
case "reversealphabetically":
|
||||
orderBy = "`badge`.slug DESC"
|
||||
case "alphabetically":
|
||||
orderBy = "`badge`.slug ASC"
|
||||
default:
|
||||
// In case the sort type is invalid, keep admin default sorting.
|
||||
ctx.Data["SortType"] = "oldest"
|
||||
orderBy = "`badge`.id ASC"
|
||||
}
|
||||
|
||||
opts.Keyword = ctx.FormTrim("q")
|
||||
opts.OrderBy = orderBy
|
||||
if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) {
|
||||
badges, count, err = user_model.SearchBadges(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("SearchBadges", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Keyword"] = opts.Keyword
|
||||
ctx.Data["Total"] = count
|
||||
ctx.Data["Badges"] = badges
|
||||
|
||||
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, tplName)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
system_model "gitea.dev/models/system"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/setting/config"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/mailer"
|
||||
|
||||
"gitea.com/go-chi/session"
|
||||
)
|
||||
|
||||
const (
|
||||
tplConfig templates.TplName = "admin/config"
|
||||
tplConfigSettings templates.TplName = "admin/config_settings/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")
|
||||
}
|
||||
|
||||
// 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["AppBuiltWith"] = setting.AppBuiltWith
|
||||
ctx.Data["RunUser"] = setting.RunUser
|
||||
ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode)
|
||||
ctx.Data["GitVersion"] = git.DefaultFeatures().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["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 = ""
|
||||
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()
|
||||
prepareStartupProblemsAlert(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.HTML(http.StatusOK, tplConfigSettings)
|
||||
}
|
||||
|
||||
func validateConfigKeyValue(dynKey, input string) error {
|
||||
opt := config.GetConfigOption(dynKey)
|
||||
if opt == nil {
|
||||
return util.NewInvalidArgumentErrorf("unknown config key: %s", dynKey)
|
||||
}
|
||||
|
||||
const limit = 64 * 1024
|
||||
if len(input) > limit {
|
||||
return util.NewInvalidArgumentErrorf("value length exceeds limit of %d", limit)
|
||||
}
|
||||
|
||||
if !json.Valid([]byte(input)) {
|
||||
return util.NewInvalidArgumentErrorf("invalid json value for key: %s", dynKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ChangeConfig(ctx *context.Context) {
|
||||
_ = ctx.Req.ParseForm()
|
||||
configKeys := ctx.Req.Form["key"]
|
||||
configValues := ctx.Req.Form["value"]
|
||||
configSettings := map[string]string{}
|
||||
loop:
|
||||
for i, key := range configKeys {
|
||||
if i >= len(configValues) {
|
||||
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
|
||||
break loop
|
||||
}
|
||||
value := configValues[i]
|
||||
|
||||
err := validateConfigKeyValue(key, value)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.JSONError(err.Error())
|
||||
} else {
|
||||
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
|
||||
}
|
||||
break loop
|
||||
}
|
||||
configSettings[key] = value
|
||||
}
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if err := system_model.SetSettings(ctx, configSettings); err != nil {
|
||||
ctx.ServerError("SetSettings", err)
|
||||
return
|
||||
}
|
||||
config.GetDynGetter().InvalidateCache()
|
||||
ctx.JSONOK()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2023 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"runtime/pprof"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/tailmsg"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func MonitorDiagnosis(ctx *context.Context) {
|
||||
seconds := min(max(ctx.FormInt64("seconds"), 1), 300)
|
||||
|
||||
httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{
|
||||
ContentType: "application/zip",
|
||||
Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")),
|
||||
ContentDisposition: httplib.ContentDispositionAttachment,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "perftrace.txt", Method: zip.Deflate, Modified: time.Now()})
|
||||
if err != nil {
|
||||
ctx.ServerError("Failed to create zip file", err)
|
||||
return
|
||||
}
|
||||
for _, record := range tailmsg.GetManager().GetTraceRecorder().GetRecords() {
|
||||
_, _ = f.Write(util.UnsafeStringToBytes(record.Time.Format(time.RFC3339)))
|
||||
_, _ = f.Write([]byte(" "))
|
||||
_, _ = f.Write(util.UnsafeStringToBytes((record.Content)))
|
||||
_, _ = f.Write([]byte("\n\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2020 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/user"
|
||||
)
|
||||
|
||||
const (
|
||||
tplEmails templates.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(count, opts.PageSize, opts.Page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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.HTTPError(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("")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/webhook"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplAdminHooks template path to render hook settings
|
||||
tplAdminHooks templates.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://docs.gitea.com/usage/webhooks")
|
||||
sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, optional.None[bool]())
|
||||
sys["BaseLink"] = setting.AppSubURL + "/-/admin/hooks"
|
||||
sys["BaseLinkNew"] = setting.AppSubURL + "/-/admin/system-hooks"
|
||||
if err != nil {
|
||||
ctx.ServerError("GetWebhooksAdmin", err)
|
||||
return
|
||||
}
|
||||
|
||||
def["Title"] = ctx.Tr("admin.defaulthooks")
|
||||
def["Description"] = ctx.Tr("admin.defaulthooks.desc", "https://docs.gitea.com/usage/webhooks")
|
||||
def["Webhooks"], err = webhook.GetDefaultWebhooks(ctx)
|
||||
def["BaseLink"] = setting.AppSubURL + "/-/admin/hooks"
|
||||
def["BaseLinkNew"] = setting.AppSubURL + "/-/admin/default-hooks"
|
||||
if err != nil {
|
||||
ctx.ServerError("GetWebhooksAdmin", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["DefaultWebhooks"] = def
|
||||
ctx.Data["SystemWebhooks"] = sys
|
||||
|
||||
ctx.HTML(http.StatusOK, tplAdminHooks)
|
||||
}
|
||||
|
||||
// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook
|
||||
func DeleteDefaultOrSystemWebhook(ctx *context.Context) {
|
||||
if err := 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")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
system_model "gitea.dev/models/system"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplNotices templates.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 := max(ctx.FormInt("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(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")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/routers/web/explore"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplOrgs templates.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,
|
||||
Types: []user_model.UserType{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)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
packages_cleanup_service "gitea.dev/services/packages/cleanup"
|
||||
)
|
||||
|
||||
const (
|
||||
tplPackagesList templates.TplName = "admin/packages/list"
|
||||
)
|
||||
|
||||
// Packages shows all packages
|
||||
func Packages(ctx *context.Context) {
|
||||
page := max(ctx.FormInt("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(total, setting.UI.PackagesPagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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.version.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")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/tailmsg"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func PerfTrace(ctx *context.Context) {
|
||||
monitorTraceCommon(ctx)
|
||||
ctx.Data["PageIsAdminMonitorPerfTrace"] = true
|
||||
ctx.Data["PerfTraceRecords"] = tailmsg.GetManager().GetTraceRecorder().GetRecords()
|
||||
ctx.HTML(http.StatusOK, tplPerfTrace)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/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.PathParamInt64("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.PathParamInt64("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) {
|
||||
// Gitea's queue 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.PathParamInt64("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))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"runtime/pprof"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/routers/web/explore"
|
||||
"gitea.dev/services/context"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
tplRepos templates.TplName = "admin/repo/list"
|
||||
tplUnadoptedRepos templates.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.AddParamFromRequest(ctx.Req)
|
||||
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.AddParamFromRequest(ctx.Req)
|
||||
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
|
||||
}
|
||||
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName)))
|
||||
if err != nil {
|
||||
ctx.ServerError("IsDir", err)
|
||||
return
|
||||
}
|
||||
if has || !exist {
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func monitorTraceCommon(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.monitor")
|
||||
ctx.Data["PageIsAdminMonitorTrace"] = true
|
||||
// Hide the performance trace tab in production, because it shows a lot of SQLs and is not that useful for end users.
|
||||
// To avoid confusing end users, do not let them know this tab. End users should "download diagnosis report" instead.
|
||||
ctx.Data["ShowAdminPerformanceTraceTab"] = !setting.IsProd
|
||||
}
|
||||
|
||||
// Stacktrace show admin monitor goroutines page
|
||||
func Stacktrace(ctx *context.Context) {
|
||||
monitorTraceCommon(ctx)
|
||||
|
||||
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.PathParam("pid")
|
||||
process.GetManager().Cancel(process.IDType(pid))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/monitor/stacktrace")
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
org_model "gitea.dev/models/organization"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/web/explore"
|
||||
user_setting "gitea.dev/routers/web/user/setting"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/mailer"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
const (
|
||||
tplUsers templates.TplName = "admin/user/list"
|
||||
tplUserNew templates.TplName = "admin/user/new"
|
||||
tplUserView templates.TplName = "admin/user/view"
|
||||
tplUserEdit templates.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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
Types: []user_model.UserType{user_model.UserTypeIndividual},
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: setting.UI.Admin.UserPagingNum,
|
||||
},
|
||||
SearchByEmail: true,
|
||||
IsActive: optional.ParseBool(statusFilterMap["is_active"]),
|
||||
IsAdmin: optional.ParseBool(statusFilterMap["is_admin"]),
|
||||
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
|
||||
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
|
||||
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
|
||||
IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones
|
||||
}, 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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form)
|
||||
return
|
||||
}
|
||||
if !password.IsComplexEnough(form.Password) {
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(errMsg, tplUserNew, &form)
|
||||
return
|
||||
}
|
||||
u.MustChangePassword = form.MustChangePassword
|
||||
}
|
||||
|
||||
if err := user_model.AdminCreateUser(ctx, u, &user_model.Meta{}, overwriteDefault); err != nil {
|
||||
switch {
|
||||
case user_model.IsErrUserAlreadyExist(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tplUserNew, &form)
|
||||
case user_model.IsErrEmailAlreadyUsed(err):
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplUserNew, &form)
|
||||
case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err):
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplUserNew, &form)
|
||||
case db.IsErrNameReserved(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tplUserNew, &form)
|
||||
case db.IsErrNamePatternNotAllowed(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplUserNew, &form)
|
||||
case db.IsErrNameCharsNotAllowed(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(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.PathParamInt64("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,
|
||||
IncludeVisibility: structs.VisibleTypePrivate,
|
||||
})
|
||||
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["DisableGitHooks"] = setting.DisableGitHooks
|
||||
ctx.Data["DisableImportLocal"] = !setting.ImportLocalPaths
|
||||
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, ctx.Doer); err != nil {
|
||||
switch {
|
||||
case user_model.IsErrUserIsNotLocal(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_change_not_local_user"), tplUserEdit, &form)
|
||||
case user_model.IsErrUserAlreadyExist(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tplUserEdit, &form)
|
||||
case db.IsErrNameReserved(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", form.UserName), tplUserEdit, &form)
|
||||
case db.IsErrNamePatternNotAllowed(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", form.UserName), tplUserEdit, &form)
|
||||
case db.IsErrNameCharsNotAllowed(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form)
|
||||
case errors.Is(err, password.ErrComplexity):
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form)
|
||||
case errors.Is(err, password.ErrIsPwned):
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplUserEdit, &form)
|
||||
case password.IsErrIsPwnedRequest(err):
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form)
|
||||
default:
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if form.Email != "" {
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, u, form.Email); err != nil {
|
||||
switch {
|
||||
case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err):
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplUserEdit, &form)
|
||||
case user_model.IsErrEmailAlreadyUsed(err):
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(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),
|
||||
IsActive: optional.Some(form.Active),
|
||||
IsAdmin: user_service.UpdateOptionFieldFromValue(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 user_model.IsErrDeleteLastAdminUser(err) {
|
||||
ctx.RenderWithErrDeprecated(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.PathParam("userid")))
|
||||
}
|
||||
|
||||
// DeleteUser response for deleting a user
|
||||
func DeleteUser(ctx *context.Context) {
|
||||
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("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.PathParam("userid")))
|
||||
return
|
||||
}
|
||||
|
||||
if err = user_service.DeleteUser(ctx, u, ctx.FormBool("purge")); err != nil {
|
||||
switch {
|
||||
case repo_model.IsErrUserOwnRepos(err):
|
||||
ctx.Flash.Error(ctx.Tr("admin.users.still_own_repo"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
|
||||
case org_model.IsErrUserHasOrgs(err):
|
||||
ctx.Flash.Error(ctx.Tr("admin.users.still_has_org"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
|
||||
case packages_model.IsErrUserOwnPackages(err):
|
||||
ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
|
||||
case user_model.IsErrDeleteLastAdminUser(err):
|
||||
ctx.Flash.Error(ctx.Tr("auth.last_admin"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("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))
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/services/forms"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, username, u.Name)
|
||||
assert.Equal(t, email, u.Email)
|
||||
assert.True(t, u.MustChangePassword)
|
||||
}
|
||||
|
||||
func TestNewUserPost_MustChangePasswordFalse(t *testing.T) {
|
||||
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)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, username, u.Name)
|
||||
assert.Equal(t, email, u.Email)
|
||||
assert.False(t, u.MustChangePassword)
|
||||
}
|
||||
|
||||
func TestNewUserPost_InvalidEmail(t *testing.T) {
|
||||
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)
|
||||
|
||||
assert.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)
|
||||
|
||||
assert.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())
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
var (
|
||||
tplTwofa templates.TplName = "user/auth/twofa"
|
||||
tplTwofaScratch templates.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 performAutoLogin(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 = linkAccountFromContext(ctx, 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
|
||||
}
|
||||
|
||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||
handleSignIn(ctx, u, remember)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.RenderWithErrDeprecated(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 performAutoLogin(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)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{})
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
// 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"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/eventsource"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/externalaccount"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/mailer"
|
||||
user_service "gitea.dev/services/user"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSignIn templates.TplName = "user/auth/signin" // for sign in page
|
||||
tplSignUp templates.TplName = "user/auth/signup" // for sign up page
|
||||
TplActivate templates.TplName = "user/auth/activate" // for activate user
|
||||
TplActivatePrompt templates.TplName = "user/auth/activate_prompt" // for showing a message for user activation
|
||||
)
|
||||
|
||||
type CommonAuthOptions struct {
|
||||
EnableCaptcha bool
|
||||
}
|
||||
|
||||
func prepareCommonAuthPageData(ctx *context.Context, opt CommonAuthOptions) {
|
||||
ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
|
||||
ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
|
||||
|
||||
// for OpenID Connect
|
||||
ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
|
||||
ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
|
||||
|
||||
if opt.EnableCaptcha {
|
||||
ctx.Data["EnableCaptcha"] = true
|
||||
ctx.Data["RecaptchaAPIScriptURL"] = strings.TrimSuffix(setting.Service.RecaptchaURL, "/") + "/api.js"
|
||||
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"] = strings.TrimSuffix(setting.Service.McaptchaURL, "/")
|
||||
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
|
||||
if setting.Service.CaptchaType == setting.ImageCaptcha {
|
||||
ctx.Data["Captcha"] = context.GetImageCaptcha()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
|
||||
log.Error("Failed to delete expired auth tokens: %v", err)
|
||||
}
|
||||
|
||||
t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName))
|
||||
if err != nil {
|
||||
switch err {
|
||||
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if t == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(ctx, t.UserID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return false, fmt.Errorf("GetUserByID: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("HasTwoFactorOrWebAuthn: %w", err)
|
||||
}
|
||||
|
||||
isSucceed = true
|
||||
|
||||
nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
||||
}
|
||||
|
||||
if err := resetLocale(ctx, u); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
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 rememberAuthRedirectLink(ctx *context.Context) {
|
||||
redirectTo := ctx.FormString("redirect_to")
|
||||
if redirectTo == "" {
|
||||
if ref, err := url.Parse(ctx.Req.Referer()); err == nil && httplib.IsCurrentGiteaSiteURL(ctx, ctx.Req.Referer()) {
|
||||
// the request paths starting with "/user/" are either:
|
||||
// * auth related pages: don't redirect back to them
|
||||
// * user settings pages: they have "require sign-in" protection already, no "referer redirect" would happen
|
||||
skipRefererRedirect := strings.HasPrefix(ref.Path, setting.AppSubURL+"/user/")
|
||||
if !skipRefererRedirect {
|
||||
redirectTo = ref.RequestURI()
|
||||
}
|
||||
}
|
||||
}
|
||||
if redirectTo != "" {
|
||||
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
|
||||
}
|
||||
}
|
||||
|
||||
func consumeAuthRedirectLink(ctx *context.Context) string {
|
||||
redirects := []string{ctx.FormString("redirect_to"), middleware.GetRedirectToCookie(ctx.Req)}
|
||||
middleware.DeleteRedirectToCookie(ctx.Resp)
|
||||
if setting.LandingPageURL == setting.LandingPageLogin {
|
||||
redirects = append(redirects, setting.AppSubURL+"/") // do not cycle-redirect to the login page
|
||||
} else {
|
||||
redirects = append(redirects, setting.AppSubURL+string(setting.LandingPageURL))
|
||||
}
|
||||
for _, link := range redirects {
|
||||
if link != "" && httplib.IsCurrentGiteaSiteURL(ctx, link) {
|
||||
return link
|
||||
}
|
||||
}
|
||||
return setting.AppSubURL + "/"
|
||||
}
|
||||
|
||||
func redirectAfterAuth(ctx *context.Context) {
|
||||
if setting.Config().Instance.MaintenanceMode.Value(ctx).IsActive() {
|
||||
// in maintenance mode, redirect to admin dashboard, it is the only accessible page
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin")
|
||||
return
|
||||
}
|
||||
ctx.RedirectToCurrentSite(consumeAuthRedirectLink(ctx))
|
||||
}
|
||||
|
||||
func performAutoLogin(ctx *context.Context) bool {
|
||||
rememberAuthRedirectLink(ctx)
|
||||
|
||||
isSucceed, err := autoSignIn(ctx) // try to auto-login
|
||||
if err != nil {
|
||||
if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
|
||||
ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
|
||||
return false
|
||||
}
|
||||
ctx.ServerError("autoSignIn", err)
|
||||
return true
|
||||
}
|
||||
|
||||
if isSucceed {
|
||||
redirectAfterAuth(ctx)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func performAutoLoginOAuth2(ctx *context.Context, data *preparedSignInData) bool {
|
||||
// If only 1 OAuth provider is present and other login methods are disabled, redirect to the OAuth provider.
|
||||
onlySingleOAuth2 := len(data.oauth2Providers) == 1 &&
|
||||
!setting.Service.EnablePasswordSignInForm &&
|
||||
!setting.Service.EnableOpenIDSignIn &&
|
||||
!setting.Service.EnablePasskeyAuth &&
|
||||
!data.enableSSPI
|
||||
|
||||
if !onlySingleOAuth2 {
|
||||
return false
|
||||
}
|
||||
|
||||
skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.PathEscape(data.oauth2Providers[0].DisplayName())
|
||||
if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" {
|
||||
skipToOAuthURL += "?redirect_to=" + url.QueryEscape(redirectTo)
|
||||
}
|
||||
ctx.Redirect(skipToOAuthURL)
|
||||
return true
|
||||
}
|
||||
|
||||
type preparedSignInData struct {
|
||||
oauth2Providers []oauth2.Provider
|
||||
enableSSPI bool
|
||||
}
|
||||
|
||||
func prepareSignInPageData(ctx *context.Context) (ret preparedSignInData) {
|
||||
var err error
|
||||
ret.enableSSPI = auth.IsSSPIEnabled(ctx)
|
||||
ret.oauth2Providers, err = oauth2.GetOAuth2Providers(ctx, optional.Some(true))
|
||||
if err != nil {
|
||||
log.Error("Failed to get OAuth2 providers: %v", err)
|
||||
}
|
||||
ctx.Data["Title"] = ctx.Tr("sign_in")
|
||||
ctx.Data["OAuth2Providers"] = ret.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"] = ret.enableSSPI
|
||||
|
||||
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
||||
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin,
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
// SignIn render sign in page
|
||||
func SignIn(ctx *context.Context) {
|
||||
if performAutoLogin(ctx) {
|
||||
return
|
||||
}
|
||||
if ctx.IsSigned {
|
||||
redirectAfterAuth(ctx)
|
||||
return
|
||||
}
|
||||
data := prepareSignInPageData(ctx)
|
||||
if performAutoLoginOAuth2(ctx, &data) {
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, tplSignIn)
|
||||
}
|
||||
|
||||
// SignInPost response for sign in request
|
||||
func SignInPost(ctx *context.Context) {
|
||||
if !setting.Service.EnablePasswordSignInForm {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
prepareSignInPageData(ctx)
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplSignIn)
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.SignInForm)
|
||||
|
||||
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
|
||||
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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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 {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Now handle 2FA:
|
||||
// First of all if the source can skip local two fa we're done
|
||||
if source.TwoFactorShouldSkip() {
|
||||
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) {
|
||||
handleSignInFull(ctx, u, remember)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
redirectAfterAuth(ctx)
|
||||
}
|
||||
|
||||
func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
if remember {
|
||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("CreateAuthTokenForUserID", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
}
|
||||
|
||||
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, []string{
|
||||
// Delete the openid, 2fa and link_account data
|
||||
"openid_verified_uri",
|
||||
"openid_signin_remember",
|
||||
"openid_determined_email",
|
||||
"openid_determined_username",
|
||||
"twofaUid",
|
||||
"twofaRemember",
|
||||
"linkAccount",
|
||||
"linkAccountData",
|
||||
}, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
}); err != nil {
|
||||
ctx.ServerError("RegenerateSession", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
|
||||
|
||||
if ctx.Locale.Language() != u.Language {
|
||||
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
|
||||
}
|
||||
|
||||
// Register last login
|
||||
if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
|
||||
// It returns ("", nil) if the required field doesn't exist.
|
||||
func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) {
|
||||
switch setting.OAuth2Client.Username {
|
||||
case setting.OAuth2UsernameEmail:
|
||||
return user_model.NormalizeUserName(gothUser.Email)
|
||||
case setting.OAuth2UsernamePreferredUsername:
|
||||
if preferredUsername, ok := gothUser.RawData["preferred_username"].(string); ok {
|
||||
return user_model.NormalizeUserName(preferredUsername)
|
||||
}
|
||||
return "", nil
|
||||
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)
|
||||
middleware.DeleteRedirectToCookie(ctx.Resp)
|
||||
}
|
||||
|
||||
// SignOut sign out from login status
|
||||
func SignOut(ctx *context.Context) {
|
||||
if ctx.Doer != nil {
|
||||
eventsource.GetManager().SendMessageBlocking(ctx.Doer.ID, &eventsource.Event{
|
||||
Name: "logout",
|
||||
Data: ctx.Session.ID(),
|
||||
})
|
||||
}
|
||||
|
||||
// prepare the sign-out URL before destroying the session
|
||||
redirectTo := buildSignOutRedirectURL(ctx)
|
||||
HandleSignOut(ctx)
|
||||
ctx.Redirect(redirectTo)
|
||||
}
|
||||
|
||||
func buildSignOutRedirectURL(ctx *context.Context) string {
|
||||
if ctx.Doer != nil && ctx.Doer.LoginType == auth.OAuth2 {
|
||||
if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// The assumption is: if reverse proxy auth is enabled, then the users should only sign-in via reverse proxy auth.
|
||||
// TODO: in the future, if we need to distinguish different sign-in methods, we need to save the sign-in method in session and check here
|
||||
if setting.Service.EnableReverseProxyAuth && setting.ReverseProxyLogoutRedirect != "" {
|
||||
return setting.ReverseProxyLogoutRedirect
|
||||
}
|
||||
return setting.AppSubURL + "/"
|
||||
}
|
||||
|
||||
func prepareSignUpPageData(ctx *context.Context) bool {
|
||||
ctx.Data["Title"] = ctx.Tr("sign_up")
|
||||
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
|
||||
ctx.Data["PageIsSignUp"] = true
|
||||
ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx)
|
||||
|
||||
hasUsers, err := user_model.HasUsers(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("HasUsers", err)
|
||||
return false
|
||||
}
|
||||
ctx.Data["IsFirstTimeRegistration"] = !hasUsers.HasAnyUser
|
||||
|
||||
oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOAuth2Providers", err)
|
||||
return false
|
||||
}
|
||||
ctx.Data["OAuth2Providers"] = oauth2Providers
|
||||
|
||||
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
||||
EnableCaptcha: setting.Service.EnableCaptcha,
|
||||
})
|
||||
|
||||
// Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true
|
||||
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SignUp render the register page
|
||||
func SignUp(ctx *context.Context) {
|
||||
if !prepareSignUpPageData(ctx) {
|
||||
return
|
||||
}
|
||||
rememberAuthRedirectLink(ctx)
|
||||
ctx.HTML(http.StatusOK, tplSignUp)
|
||||
}
|
||||
|
||||
// SignUpPost response for sign up information submission
|
||||
func SignUpPost(ctx *context.Context) {
|
||||
if !prepareSignUpPageData(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.RegisterForm)
|
||||
|
||||
// Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true
|
||||
if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
|
||||
ctx.HTTPError(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.RenderWithErrDeprecated(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form)
|
||||
return
|
||||
}
|
||||
|
||||
if form.Password != form.Retype {
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.password_not_match"), tplSignUp, &form)
|
||||
return
|
||||
}
|
||||
if len(form.Password) < setting.MinPasswordLength {
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form)
|
||||
return
|
||||
}
|
||||
if !password.IsComplexEnough(form.Password) {
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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) {
|
||||
// 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 templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) bool {
|
||||
if !createUserInContext(ctx, tpl, form, u, overwrites, possibleLinkAccountData) {
|
||||
return false
|
||||
}
|
||||
return handleUserCreated(ctx, u, possibleLinkAccountData)
|
||||
}
|
||||
|
||||
// createUserInContext creates a user and handles errors within a given context.
|
||||
// Optionally, a template can be specified.
|
||||
func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) (ok bool) {
|
||||
meta := &user_model.Meta{
|
||||
InitialIP: ctx.RemoteAddr(),
|
||||
InitialUserAgent: ctx.Req.UserAgent(),
|
||||
}
|
||||
if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil {
|
||||
if possibleLinkAccountData != nil && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
|
||||
switch setting.OAuth2Client.AccountLinking {
|
||||
case setting.OAuth2AccountLinkingAuto:
|
||||
var user *user_model.User
|
||||
user = &user_model.User{Name: u.Name}
|
||||
hasUser, err := user_model.GetIndividualUser(ctx, user)
|
||||
if !hasUser || err != nil {
|
||||
user = &user_model.User{Email: u.Email}
|
||||
hasUser, err = user_model.GetIndividualUser(ctx, user)
|
||||
if !hasUser || err != nil {
|
||||
ctx.ServerError("UserLinkAccount", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// TODO: probably we should respect 'remember' user's choice...
|
||||
oauth2LinkAccount(ctx, user, possibleLinkAccountData, true)
|
||||
return false // user is already created here, all redirects are handled
|
||||
case setting.OAuth2AccountLinkingLogin:
|
||||
showLinkingLogin(ctx, possibleLinkAccountData.AuthSourceID, possibleLinkAccountData.GothUser)
|
||||
return false // user will be created only after linking login
|
||||
}
|
||||
}
|
||||
|
||||
// handle error without a 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.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tpl, form)
|
||||
case user_model.IsErrEmailAlreadyUsed(err):
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tpl, form)
|
||||
case user_model.IsErrEmailCharIsNotSupported(err):
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tpl, form)
|
||||
case user_model.IsErrEmailInvalid(err):
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tpl, form)
|
||||
case db.IsErrNameReserved(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form)
|
||||
case db.IsErrNamePatternNotAllowed(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form)
|
||||
case db.IsErrNameCharsNotAllowed(err):
|
||||
ctx.Data["Err_UserName"] = true
|
||||
ctx.RenderWithErrDeprecated(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, possibleLinkAccountData *LinkAccountData) (ok bool) {
|
||||
// Auto-set admin for the only user.
|
||||
hasUsers, err := user_model.HasUsers(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("HasUsers", err)
|
||||
return false
|
||||
}
|
||||
if hasUsers.HasOnlyOneUser {
|
||||
// the only user is the one just created, will set it as admin
|
||||
opts := &user_service.UpdateOptions{
|
||||
IsActive: optional.Some(true),
|
||||
IsAdmin: user_service.UpdateOptionFieldFromValue(true),
|
||||
SetLastLogin: true,
|
||||
}
|
||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// update external user information
|
||||
if possibleLinkAccountData != nil {
|
||||
if err := externalaccount.EnsureLinkExternalToUser(ctx, possibleLinkAccountData.AuthSourceID, u, possibleLinkAccountData.GothUser); err != nil {
|
||||
log.Error("EnsureLinkExternalToUser failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// for active user or the first (admin) user, we don't need to send confirmation email
|
||||
if u.IsActive || u.ID == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
if setting.Service.RegisterManualConfirm {
|
||||
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.manual_activation_only"))
|
||||
return false
|
||||
}
|
||||
|
||||
sendActivateEmail(ctx, u)
|
||||
return false
|
||||
}
|
||||
|
||||
func renderActivationPromptMessage(ctx *context.Context, msg template.HTML) {
|
||||
ctx.Data["ActivationPromptMessage"] = msg
|
||||
ctx.HTML(http.StatusOK, TplActivatePrompt)
|
||||
}
|
||||
|
||||
func sendActivateEmail(ctx *context.Context, u *user_model.User) {
|
||||
if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
|
||||
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
|
||||
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
||||
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
|
||||
return
|
||||
}
|
||||
|
||||
mailer.SendActivateAccountMail(ctx.Locale, u)
|
||||
|
||||
activeCodeLives := timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
|
||||
msgHTML := ctx.Locale.Tr("auth.confirmation_mail_sent_prompt_ex", u.Email, activeCodeLives)
|
||||
renderActivationPromptMessage(ctx, msgHTML)
|
||||
}
|
||||
|
||||
func renderActivationVerifyPassword(ctx *context.Context, code string) {
|
||||
ctx.Data["ActivationCode"] = code
|
||||
ctx.Data["NeedVerifyLocalPassword"] = true
|
||||
ctx.HTML(http.StatusOK, TplActivate)
|
||||
}
|
||||
|
||||
func renderActivationChangeEmail(ctx *context.Context) {
|
||||
ctx.HTML(http.StatusOK, TplActivate)
|
||||
}
|
||||
|
||||
// Activate render activate user page
|
||||
func Activate(ctx *context.Context) {
|
||||
code := ctx.FormString("code")
|
||||
|
||||
if code == "" {
|
||||
if ctx.Doer == nil {
|
||||
ctx.Redirect(setting.AppSubURL + "/user/login")
|
||||
return
|
||||
} else if ctx.Doer.IsActive {
|
||||
ctx.Redirect(setting.AppSubURL + "/")
|
||||
return
|
||||
}
|
||||
|
||||
if setting.MailService == nil || !setting.Service.RegisterEmailConfirm {
|
||||
renderActivationPromptMessage(ctx, ctx.Tr("auth.disable_register_mail"))
|
||||
return
|
||||
}
|
||||
|
||||
// Resend confirmation email. FIXME: ideally this should be in a POST request
|
||||
sendActivateEmail(ctx, ctx.Doer)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
|
||||
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
|
||||
if user == nil { // if code is wrong
|
||||
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
|
||||
return
|
||||
}
|
||||
|
||||
// if account is local account, verify password
|
||||
if user.LoginSource == 0 {
|
||||
renderActivationVerifyPassword(ctx, code)
|
||||
return
|
||||
}
|
||||
|
||||
handleAccountActivation(ctx, user)
|
||||
}
|
||||
|
||||
// ActivatePost handles account activation with password check
|
||||
func ActivatePost(ctx *context.Context) {
|
||||
code := ctx.FormString("code")
|
||||
if ctx.Doer != nil && ctx.Doer.IsActive {
|
||||
ctx.Redirect(setting.AppSubURL + "/user/activate") // it will redirect again to the correct page
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
newEmail := strings.TrimSpace(ctx.FormString("change_email"))
|
||||
if ctx.Doer != nil && newEmail != "" && !strings.EqualFold(ctx.Doer.Email, newEmail) {
|
||||
if user_model.ValidateEmail(newEmail) != nil {
|
||||
ctx.Flash.Error(ctx.Locale.Tr("form.email_invalid"), true)
|
||||
renderActivationChangeEmail(ctx)
|
||||
return
|
||||
}
|
||||
err := user_model.ChangeInactivePrimaryEmail(ctx, ctx.Doer.ID, ctx.Doer.Email, newEmail)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Locale.Tr("admin.emails.not_updated", newEmail), true)
|
||||
renderActivationChangeEmail(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Doer.Email = newEmail
|
||||
}
|
||||
// FIXME: at the moment, GET request handles the "send confirmation email" action. But the old code does this redirect and then send a confirmation email.
|
||||
ctx.Redirect(setting.AppSubURL + "/user/activate")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
|
||||
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
|
||||
if user == nil { // if code is wrong
|
||||
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
|
||||
return
|
||||
}
|
||||
|
||||
// if account is local account, verify password
|
||||
if user.LoginSource == 0 {
|
||||
password := ctx.FormString("password")
|
||||
if password == "" {
|
||||
renderActivationVerifyPassword(ctx, code)
|
||||
return
|
||||
}
|
||||
if !user.ValidatePassword(password) {
|
||||
ctx.Flash.Error(ctx.Locale.Tr("auth.invalid_password"), true)
|
||||
renderActivationVerifyPassword(ctx, code)
|
||||
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(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,
|
||||
"uname": user.Name,
|
||||
}); 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"))
|
||||
redirectAfterAuth(ctx)
|
||||
}
|
||||
|
||||
// ActivateEmail render the activate email page
|
||||
func ActivateEmail(ctx *context.Context) {
|
||||
code := ctx.FormString("code")
|
||||
emailStr := ctx.FormString("email")
|
||||
|
||||
// Verify code.
|
||||
if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil {
|
||||
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"))
|
||||
|
||||
if u, err := user_model.GetUserByID(ctx, email.UID); err != nil {
|
||||
log.Warn("GetUserByID: %d", email.UID)
|
||||
} else {
|
||||
// Allow user to validate more emails
|
||||
_ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: e-mail verification does not require the user to be logged in,
|
||||
// so this could be redirecting to the login page.
|
||||
// Should users be logged in automatically here? (consider 2FA requirements, etc.)
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/gothic"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) {
|
||||
cfg.Provider = util.IfZero(cfg.Provider, "gitea")
|
||||
err := auth_model.CreateSource(t.Context(), &auth_model.Source{
|
||||
Type: auth_model.OAuth2,
|
||||
Name: authName,
|
||||
IsActive: true,
|
||||
Cfg: &cfg,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWebAuthUserLogin(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))
|
||||
}
|
||||
|
||||
func TestWebAuthOAuth2(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||
|
||||
_ = oauth2.Init(t.Context())
|
||||
addOAuth2Source(t, "dummy+auth's source", oauth2.Source{})
|
||||
|
||||
t.Run("OAuth2MissingField", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
|
||||
return goth.User{Provider: "dummy+auth's source", UserID: "dummy-user"}, nil
|
||||
})()
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
||||
ctx, resp := contexttest.MockContext(t, "/user/oauth2/..../callback?code=dummy-code", mockOpt)
|
||||
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
|
||||
SignInOAuthCallback(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
|
||||
|
||||
// then the user will be redirected to the link account page, and see a message about the missing fields
|
||||
ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt)
|
||||
LinkAccount(ctx)
|
||||
assert.Equal(t, template.HTML("auth.oauth_callback_unable_auto_reg:dummy+auth's source,email"), ctx.Data["AutoRegistrationFailedPrompt"])
|
||||
})
|
||||
|
||||
t.Run("OAuth2CallbackError", func(t *testing.T) {
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
||||
ctx, resp := contexttest.MockContext(t, "/user/oauth2/...../callback", mockOpt)
|
||||
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
|
||||
SignInOAuthCallback(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
assert.Equal(t, "/user/login", test.RedirectURL(resp))
|
||||
assert.Contains(t, ctx.Flash.ErrorMsg, "auth.oauth.signin.error.general")
|
||||
})
|
||||
|
||||
t.Run("RedirectSingleProvider", func(t *testing.T) {
|
||||
enablePassword := &setting.Service.EnablePasswordSignInForm
|
||||
enableOpenID := &setting.Service.EnableOpenIDSignIn
|
||||
enablePasskey := &setting.Service.EnablePasskeyAuth
|
||||
defer test.MockVariableValue(enablePassword, false)()
|
||||
defer test.MockVariableValue(enableOpenID, false)()
|
||||
defer test.MockVariableValue(enablePasskey, false)()
|
||||
|
||||
testSignIn := func(t *testing.T, link string, expectedCode int, expectedRedirect string) {
|
||||
ctx, resp := contexttest.MockContext(t, link)
|
||||
SignIn(ctx)
|
||||
assert.Equal(t, expectedCode, resp.Code)
|
||||
if expectedCode == http.StatusSeeOther {
|
||||
assert.Equal(t, expectedRedirect, test.RedirectURL(resp))
|
||||
}
|
||||
}
|
||||
testSignIn(t, "/user/login", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source")
|
||||
testSignIn(t, "/user/login?redirect_to=/", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source?redirect_to=%2F")
|
||||
|
||||
*enablePassword, *enableOpenID, *enablePasskey = true, false, false
|
||||
testSignIn(t, "/user/login", http.StatusOK, "")
|
||||
*enablePassword, *enableOpenID, *enablePasskey = false, true, false
|
||||
testSignIn(t, "/user/login", http.StatusOK, "")
|
||||
*enablePassword, *enableOpenID, *enablePasskey = false, false, true
|
||||
testSignIn(t, "/user/login", http.StatusOK, "")
|
||||
|
||||
*enablePassword, *enableOpenID, *enablePasskey = false, false, false
|
||||
addOAuth2Source(t, "dummy-auth-source-2", oauth2.Source{})
|
||||
testSignIn(t, "/user/login", http.StatusOK, "")
|
||||
})
|
||||
|
||||
t.Run("OIDCLogout", func(t *testing.T) {
|
||||
var mockServer *httptest.Server
|
||||
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"issuer": "` + mockServer.URL + `",
|
||||
"authorization_endpoint": "` + mockServer.URL + `/authorize",
|
||||
"token_endpoint": "` + mockServer.URL + `/token",
|
||||
"userinfo_endpoint": "` + mockServer.URL + `/userinfo",
|
||||
"end_session_endpoint": "https://example.com/oidc-logout?oidc-key=oidc-val"
|
||||
}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
addOAuth2Source(t, "oidc-auth-source", oauth2.Source{
|
||||
Provider: "openidConnect",
|
||||
ClientID: "mock-client-id",
|
||||
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
|
||||
})
|
||||
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source")
|
||||
require.NoError(t, err)
|
||||
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
||||
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
|
||||
ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
|
||||
SignOut(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
u, err := url.Parse(test.RedirectURL(resp))
|
||||
require.NoError(t, err)
|
||||
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
|
||||
assert.Equal(t, expectedValues, u.Query())
|
||||
u.RawQuery = ""
|
||||
assert.Equal(t, "https://example.com/oidc-logout", u.String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/externalaccount"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
var tplLinkAccount templates.TplName = "user/auth/link_account"
|
||||
|
||||
func prepareLinkAccountPageData(ctx *context.Context) {
|
||||
// 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
|
||||
|
||||
// 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"
|
||||
ctx.Data["ShowRegistrationButton"] = false
|
||||
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
|
||||
|
||||
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
||||
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha,
|
||||
})
|
||||
}
|
||||
|
||||
// LinkAccount shows the page where the user can decide to login or create a new account
|
||||
func LinkAccount(ctx *context.Context) {
|
||||
prepareLinkAccountPageData(ctx)
|
||||
|
||||
linkAccountData := oauth2GetLinkAccountData(ctx)
|
||||
|
||||
// If you'd like to quickly debug the "link account" page layout, just uncomment the blow line
|
||||
// Don't worry, when the below line exists, the lint won't pass: ineffectual assignment to gothUser (ineffassign)
|
||||
// linkAccountData = &LinkAccountData{authSource, gothUser} // intentionally use invalid data to avoid pass the registration check
|
||||
|
||||
if linkAccountData == nil {
|
||||
// no account in session, so just redirect to the login page, then the user could restart the process
|
||||
ctx.Redirect(setting.AppSubURL + "/user/login")
|
||||
return
|
||||
}
|
||||
|
||||
if missingFields, ok := linkAccountData.GothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok {
|
||||
ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", linkAccountData.GothUser.Provider, strings.Join(missingFields, ","))
|
||||
}
|
||||
|
||||
uname, err := extractUserNameFromOAuth2(&linkAccountData.GothUser)
|
||||
if err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
email := linkAccountData.GothUser.Email
|
||||
ctx.Data["user_name"] = uname
|
||||
ctx.Data["email"] = email
|
||||
|
||||
if email != "" {
|
||||
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 uname != "" {
|
||||
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 templates.TplName, invoker string, err error) {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
|
||||
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.Data["user_exists"] = true
|
||||
ctx.RenderWithErrDeprecated(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 {
|
||||
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["LinkAccountModeSignIn"] = true
|
||||
|
||||
prepareLinkAccountPageData(ctx)
|
||||
|
||||
linkAccountData := oauth2GetLinkAccountData(ctx)
|
||||
if linkAccountData == 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
|
||||
}
|
||||
|
||||
oauth2LinkAccount(ctx, u, linkAccountData, signInForm.Remember)
|
||||
}
|
||||
|
||||
func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData *LinkAccountData, remember bool) {
|
||||
oauth2SignInSync(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
// If this user is enrolled in 2FA, we can't sign the user in just yet.
|
||||
// Instead, redirect them to the 2FA authentication page.
|
||||
// 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, linkAccountData.AuthSourceID, u, linkAccountData.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)
|
||||
|
||||
ctx.Data["LinkAccountModeRegister"] = true
|
||||
|
||||
prepareLinkAccountPageData(ctx)
|
||||
|
||||
linkAccountData := oauth2GetLinkAccountData(ctx)
|
||||
if linkAccountData == nil {
|
||||
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
|
||||
return
|
||||
}
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplLinkAccount)
|
||||
return
|
||||
}
|
||||
|
||||
if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
|
||||
context.VerifyCaptcha(ctx, tplLinkAccount, form)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !form.IsEmailDomainAllowed() {
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
u := &user_model.User{
|
||||
Name: form.UserName,
|
||||
Email: form.Email,
|
||||
Passwd: form.Password,
|
||||
LoginType: auth.OAuth2,
|
||||
LoginSource: linkAccountData.AuthSourceID,
|
||||
LoginName: linkAccountData.GothUser.UserID,
|
||||
}
|
||||
|
||||
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, linkAccountData) {
|
||||
// error already handled
|
||||
return
|
||||
}
|
||||
|
||||
oauth2SignInSync(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
authSource, err := auth.GetSourceByID(ctx, linkAccountData.AuthSourceID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetSourceByID", err)
|
||||
return
|
||||
}
|
||||
source := authSource.Cfg.(*oauth2.Source)
|
||||
if err := syncGroupsToTeams(ctx, source, &linkAccountData.GothUser, u); err != nil {
|
||||
ctx.ServerError("SyncGroupsToTeams", err)
|
||||
return
|
||||
}
|
||||
|
||||
handleSignIn(ctx, u, false)
|
||||
}
|
||||
|
||||
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
|
||||
linkAccountData := oauth2GetLinkAccountData(ctx)
|
||||
if linkAccountData == nil {
|
||||
return errors.New("not in LinkAccount session")
|
||||
}
|
||||
return externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, user, linkAccountData.GothUser)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
auth_module "gitea.dev/modules/auth"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
source_service "gitea.dev/services/auth/source"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/externalaccount"
|
||||
user_service "gitea.dev/services/user"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/gothic"
|
||||
go_oauth2 "golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// SignInOAuth handles the OAuth2 login buttons
|
||||
func SignInOAuth(ctx *context.Context) {
|
||||
authName := ctx.PathParam("provider")
|
||||
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
|
||||
if err != nil {
|
||||
ctx.ServerError("SignIn", err)
|
||||
return
|
||||
}
|
||||
|
||||
rememberAuthRedirectLink(ctx)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); 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); 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) {
|
||||
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, "\n"), true)
|
||||
}
|
||||
|
||||
// first look if the provider is still active
|
||||
authName := ctx.PathParam("provider")
|
||||
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
|
||||
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.general", callbackErr.Description))
|
||||
}
|
||||
ctx.Redirect(setting.AppSubURL + "/user/login")
|
||||
return
|
||||
}
|
||||
if err, ok := err.(*go_oauth2.RetrieveError); ok {
|
||||
ctx.Flash.Error("OAuth2 RetrieveError: " + err.Error())
|
||||
ctx.Redirect(setting.AppSubURL + "/user/login")
|
||||
return
|
||||
}
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
|
||||
if u == nil {
|
||||
if ctx.Doer != nil {
|
||||
// attach user to the current signed-in user
|
||||
err = externalaccount.LinkAccountToUser(ctx, authSource.ID, 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
|
||||
var missingFields []string
|
||||
if gothUser.UserID == "" {
|
||||
missingFields = append(missingFields, "sub")
|
||||
}
|
||||
if gothUser.Email == "" {
|
||||
missingFields = append(missingFields, "email")
|
||||
}
|
||||
uname, err := extractUserNameFromOAuth2(&gothUser)
|
||||
if err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
if uname == "" {
|
||||
switch setting.OAuth2Client.Username {
|
||||
case setting.OAuth2UsernameNickname:
|
||||
missingFields = append(missingFields, "nickname")
|
||||
case setting.OAuth2UsernamePreferredUsername:
|
||||
missingFields = append(missingFields, "preferred_username")
|
||||
} // else: "UserID" and "Email" have been handled above separately
|
||||
}
|
||||
if len(missingFields) > 0 {
|
||||
log.Error(`OAuth2 auto registration (ENABLE_AUTO_REGISTRATION) is enabled but OAuth2 provider %q doesn't return required fields: %s. `+
|
||||
`Suggest to: disable auto registration, or make OPENID_CONNECT_SCOPES (for OpenIDConnect) / Authentication Source Scopes (for Admin panel) to request all required fields, and the fields shouldn't be empty.`,
|
||||
authSource.Name, strings.Join(missingFields, ","))
|
||||
// The RawData is the only way to pass the missing fields to the another page at the moment, other ways all have various problems:
|
||||
// by session or cookie: difficult to clean or reset; by URL: could be injected with uncontrollable content; by ctx.Flash: the link_account page is a mess ...
|
||||
// Since the RawData is for the provider's data, so we need to use our own prefix here to avoid conflict.
|
||||
if gothUser.RawData == nil {
|
||||
gothUser.RawData = make(map[string]any)
|
||||
}
|
||||
gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields
|
||||
showLinkingLogin(ctx, authSource.ID, gothUser)
|
||||
return
|
||||
}
|
||||
u = &user_model.User{
|
||||
Name: uname,
|
||||
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(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue
|
||||
u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
|
||||
|
||||
linkAccountData := &LinkAccountData{authSource.ID, gothUser}
|
||||
if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingDisabled {
|
||||
linkAccountData = nil
|
||||
}
|
||||
if !createAndHandleCreatedUser(ctx, "", nil, u, overwriteDefault, linkAccountData) {
|
||||
// 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, authSource.ID, 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 optional.Option[user_service.UpdateOptionField[bool]], isRestricted optional.Option[bool]) {
|
||||
groups := getClaimedGroups(source, gothUser)
|
||||
|
||||
if source.AdminGroup != "" {
|
||||
isAdmin = user_service.UpdateOptionFieldFromSync(groups.Contains(source.AdminGroup))
|
||||
}
|
||||
if source.RestrictedGroup != "" {
|
||||
isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
|
||||
}
|
||||
|
||||
return isAdmin, isRestricted
|
||||
}
|
||||
|
||||
type LinkAccountData struct {
|
||||
AuthSourceID int64
|
||||
GothUser goth.User
|
||||
}
|
||||
|
||||
func init() {
|
||||
gob.Register(LinkAccountData{}) // TODO: CHI-SESSION-GOB-REGISTER
|
||||
}
|
||||
|
||||
func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
|
||||
v, ok := ctx.Session.Get("linkAccountData").(LinkAccountData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
|
||||
return updateSession(ctx, nil, map[string]any{
|
||||
"linkAccountData": linkAccountData,
|
||||
})
|
||||
}
|
||||
|
||||
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
|
||||
if err := Oauth2SetLinkAccountData(ctx, LinkAccountData{authSourceID, gothUser}); err != nil {
|
||||
ctx.ServerError("Oauth2SetLinkAccountData", err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(setting.AppSubURL + "/user/link_account")
|
||||
}
|
||||
|
||||
var oauth2AvatarHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_model.User) {
|
||||
if !setting.OAuth2Client.UpdateAvatar || len(avatarURL) == 0 {
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, nil)
|
||||
if err != nil {
|
||||
log.Warn("invalid avatar URL %q: %v", avatarURL, err)
|
||||
return
|
||||
}
|
||||
// Some hosts (e.g. Wikimedia) reject Go's default User-Agent.
|
||||
req.Header.Set("User-Agent", "Gitea "+setting.AppVer)
|
||||
|
||||
resp, err := oauth2AvatarHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
log.Warn("fetch %q failed: %v", avatarURL, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Warn("fetch %q returned status %d", avatarURL, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
|
||||
if err != nil {
|
||||
log.Warn("read body from %q failed: %v", avatarURL, err)
|
||||
return
|
||||
}
|
||||
if int64(len(data)) > setting.Avatar.MaxFileSize {
|
||||
log.Warn("avatar from %q exceeds max size %d", avatarURL, setting.Avatar.MaxFileSize)
|
||||
return
|
||||
}
|
||||
if err := user_service.UploadAvatar(ctx, u, data); err != nil {
|
||||
log.Warn("UploadAvatar for user %q failed: %v", u.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
|
||||
oauth2SignInSync(ctx, authSource.ID, u, gothUser)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
needs2FA := false
|
||||
if !authSource.TwoFactorShouldSkip() {
|
||||
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
||||
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
needs2FA = err == nil
|
||||
}
|
||||
|
||||
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
||||
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
|
||||
if err != nil {
|
||||
ctx.ServerError("UnmarshalGroupTeamMapping", err)
|
||||
return
|
||||
}
|
||||
|
||||
groups := getClaimedGroups(oauth2Source, &gothUser)
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
|
||||
// Reactivate user if they are deactivated
|
||||
if !u.IsActive {
|
||||
opts.IsActive = optional.Some(true)
|
||||
}
|
||||
|
||||
// Update GroupClaims
|
||||
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
||||
|
||||
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 := externalaccount.EnsureLinkExternalToUser(ctx, authSource.ID, u, gothUser); err != nil {
|
||||
ctx.ServerError("EnsureLinkExternalToUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Register last login
|
||||
opts.SetLastLogin = true
|
||||
|
||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := resetLocale(ctx, u); err != nil {
|
||||
ctx.ServerError("resetLocale", err)
|
||||
return
|
||||
}
|
||||
|
||||
redirectAfterAuth(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", 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")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 nil, goth.User{}, err
|
||||
}
|
||||
|
||||
return nil, goth.User{}, errCallback{
|
||||
Code: errorName,
|
||||
Description: errorDescription,
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed to authenticate through goth.
|
||||
gothUser, err := oauth2Source.Callback(request, response)
|
||||
if err != nil {
|
||||
if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
|
||||
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)
|
||||
log.Error("oauth2Source.Callback failed: %v", err)
|
||||
} else {
|
||||
err = errCallback{Code: "internal", Description: err.Error()}
|
||||
}
|
||||
return nil, goth.User{}, err
|
||||
}
|
||||
|
||||
if oauth2Source.RequiredClaimName != "" {
|
||||
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
|
||||
if !has {
|
||||
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
||||
}
|
||||
|
||||
if oauth2Source.RequiredClaimValue != "" {
|
||||
groups := claimValueToStringSet(claimInterface)
|
||||
|
||||
if !groups.Contains(oauth2Source.RequiredClaimValue) {
|
||||
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user := &user_model.User{
|
||||
LoginName: gothUser.UserID,
|
||||
LoginType: auth.OAuth2,
|
||||
LoginSource: authSource.ID,
|
||||
}
|
||||
|
||||
hasUser, err := user_model.GetIndividualUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, goth.User{}, err
|
||||
}
|
||||
|
||||
if hasUser {
|
||||
return user, gothUser, nil
|
||||
}
|
||||
|
||||
// search in external linked users
|
||||
externalLoginUser := &user_model.ExternalLoginUser{
|
||||
ExternalID: gothUser.UserID,
|
||||
LoginSourceID: authSource.ID,
|
||||
}
|
||||
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
|
||||
if err != nil {
|
||||
return nil, goth.User{}, err
|
||||
}
|
||||
if hasUser {
|
||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
|
||||
return user, gothUser, err
|
||||
}
|
||||
|
||||
// no user found to login
|
||||
return nil, gothUser, nil
|
||||
}
|
||||
|
||||
// buildOIDCEndSessionURL constructs an OIDC RP-Initiated Logout URL for the
|
||||
// given user. Returns "" if the user's auth source is not OIDC or doesn't
|
||||
// advertise an end_session_endpoint.
|
||||
func buildOIDCEndSessionURL(ctx *context.Context, doer *user_model.User) string {
|
||||
authSource, err := auth.GetSourceByID(ctx, doer.LoginSource)
|
||||
if err != nil {
|
||||
log.Error("Failed to get auth source for OIDC logout (source=%d): %v", doer.LoginSource, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
oauth2Cfg, ok := authSource.Cfg.(*oauth2.Source)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
endSessionEndpoint := oauth2.GetOIDCEndSessionEndpoint(authSource.Name)
|
||||
if endSessionEndpoint == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
endSessionURL, err := url.Parse(endSessionEndpoint)
|
||||
if err != nil {
|
||||
log.Error("Failed to parse end_session_endpoint %q: %v", endSessionEndpoint, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// RP-Initiated Logout 1.0: use client_id to identify the client to the IdP.
|
||||
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
|
||||
params := endSessionURL.Query()
|
||||
params.Set("client_id", oauth2Cfg.ClientID)
|
||||
|
||||
// AWS Cognito uses "logout_uri" instead of the standard "post_logout_redirect_uri"
|
||||
redirectURI := httplib.GuessCurrentAppURL(ctx)
|
||||
if oauth2Cfg.Provider == oauth2.ProviderNameAwsCognito {
|
||||
params.Set("logout_uri", redirectURI)
|
||||
} else {
|
||||
params.Set("post_logout_redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
endSessionURL.RawQuery = params.Encode()
|
||||
return endSessionURL.String()
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/httpauth"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/oauth2_provider"
|
||||
|
||||
"gitea.com/go-chi/binding"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
tplGrantAccess templates.TplName = "user/auth/grant"
|
||||
tplGrantError templates.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)
|
||||
}
|
||||
|
||||
// errCallback represents a oauth2 callback error
|
||||
type errCallback struct {
|
||||
Code string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (err errCallback) Error() string {
|
||||
return err.Description
|
||||
}
|
||||
|
||||
type userInfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
Picture string `json:"picture"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
// 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="Gitea OAuth2"`)
|
||||
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
|
||||
return
|
||||
}
|
||||
|
||||
response := &userInfoResponse{
|
||||
Sub: strconv.FormatInt(ctx.Doer.ID, 10),
|
||||
Name: ctx.Doer.DisplayName(),
|
||||
PreferredUsername: ctx.Doer.Name,
|
||||
Email: ctx.Doer.Email,
|
||||
Picture: ctx.Doer.AvatarLink(ctx),
|
||||
}
|
||||
|
||||
var accessTokenScope auth.AccessTokenScope
|
||||
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
||||
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
|
||||
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
|
||||
}
|
||||
}
|
||||
|
||||
// since version 1.22 does not verify if groups should be public-only,
|
||||
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
|
||||
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
|
||||
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
|
||||
if err != nil {
|
||||
ctx.ServerError("Oauth groups for user", err)
|
||||
return
|
||||
}
|
||||
response.Groups = groups
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// IntrospectOAuth introspects an oauth token
|
||||
func IntrospectOAuth(ctx *context.Context) {
|
||||
clientIDValid := false
|
||||
authHeader := ctx.Req.Header.Get("Authorization")
|
||||
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
|
||||
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
||||
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.HTTPError(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
|
||||
}
|
||||
if !clientIDValid {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea OAuth2"`)
|
||||
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_provider.ParseToken(form.Token, oauth2_provider.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.RegisteredClaims = oauth2_provider.NewJwtRegisteredClaimsFromUser(app.ClientID, grant.UserID, nil /*exp*/)
|
||||
}
|
||||
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 {
|
||||
var errstring strings.Builder
|
||||
for _, e := range errs {
|
||||
errstring.WriteString(e.Error() + "\n")
|
||||
}
|
||||
ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring.String()))
|
||||
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", "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("CodeChallenge", 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)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect if user already granted access and the application is confidential or trusted otherwise
|
||||
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
|
||||
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
|
||||
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
return
|
||||
}
|
||||
redirect, err := code.GenerateRedirectURI(form.State)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
return
|
||||
}
|
||||
// Update nonce to reflect the new session
|
||||
if len(form.Nonce) > 0 {
|
||||
err := grant.SetNonce(ctx, form.Nonce)
|
||||
if err != nil {
|
||||
log.Error("Unable to update nonce: %v", err)
|
||||
}
|
||||
}
|
||||
ctx.Redirect(redirect.String())
|
||||
return
|
||||
}
|
||||
|
||||
// check if additional scopes
|
||||
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
|
||||
|
||||
// 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)
|
||||
log.Error(err.Error())
|
||||
return
|
||||
}
|
||||
err = ctx.Session.Set("redirect_uri", form.RedirectURI)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
log.Error(err.Error())
|
||||
return
|
||||
}
|
||||
err = ctx.Session.Set("state", form.State)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
log.Error(err.Error())
|
||||
return
|
||||
}
|
||||
// Here we're just going to try to release the session early
|
||||
if err := ctx.Session.Release(); err != nil {
|
||||
// we'll tolerate errors here as they *should* get saved elsewhere
|
||||
log.Error("Unable to save changes to the session: %v", err)
|
||||
}
|
||||
ctx.HTML(http.StatusOK, tplGrantAccess)
|
||||
}
|
||||
|
||||
// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
|
||||
func GrantApplicationOAuth(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.GrantApplicationForm)
|
||||
if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
|
||||
ctx.Session.Get("redirect_uri") != form.RedirectURI {
|
||||
ctx.HTTPError(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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
redirect, err := code.GenerateRedirectURI(form.State)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(redirect.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
|
||||
func OIDCWellKnown(ctx *context.Context) {
|
||||
if !setting.OAuth2.Enabled {
|
||||
http.NotFound(ctx.Resp, ctx.Req)
|
||||
return
|
||||
}
|
||||
jwtRegisteredClaims := oauth2_provider.NewJwtRegisteredClaimsFromUser("well-known", 0, nil)
|
||||
ctx.Data["OidcIssuer"] = jwtRegisteredClaims.Issuer // use the consistent issuer from the JWT registered claims
|
||||
ctx.Data["OidcBaseUrl"] = strings.TrimSuffix(setting.AppURL, "/")
|
||||
ctx.Data["SigningKeyMethodAlg"] = oauth2_provider.DefaultSigningKey.SigningMethod().Alg()
|
||||
ctx.JSONTemplate("user/auth/oidc_wellknown")
|
||||
}
|
||||
|
||||
// OIDCKeys generates the JSON Web Key Set
|
||||
func OIDCKeys(ctx *context.Context) {
|
||||
jwk, err := oauth2_provider.DefaultSigningKey.ToJWK()
|
||||
if err != nil {
|
||||
log.Error("Error converting signing key to JWK: %v", err)
|
||||
ctx.HTTPError(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 == "" {
|
||||
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
|
||||
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
|
||||
if !ok || parsed.BasicAuth == nil {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
||||
ErrorDescription: "cannot parse basic auth header",
|
||||
})
|
||||
return
|
||||
}
|
||||
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
||||
// validate that any fields present in the form match the Basic auth header
|
||||
if form.ClientID != "" && form.ClientID != clientID {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
||||
ErrorDescription: "client_id in request body inconsistent with Authorization header",
|
||||
})
|
||||
return
|
||||
}
|
||||
form.ClientID = clientID
|
||||
if form.ClientSecret != "" && form.ClientSecret != clientSecret {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
||||
ErrorDescription: "client_secret in request body inconsistent with Authorization header",
|
||||
})
|
||||
return
|
||||
}
|
||||
form.ClientSecret = clientSecret
|
||||
}
|
||||
}
|
||||
|
||||
serverKey := oauth2_provider.DefaultSigningKey
|
||||
clientKey := serverKey
|
||||
if serverKey.IsSymmetric() {
|
||||
var err error
|
||||
clientKey, err = oauth2_provider.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
|
||||
if err != nil {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnsupportedGrantType,
|
||||
ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
|
||||
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
||||
if err != nil {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
|
||||
ErrorDescription: errorDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
|
||||
if err != nil {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
|
||||
ErrorDescription: "grant does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
if grant.ApplicationID != app.ID {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
|
||||
ErrorDescription: "refresh token belongs to a different client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// check if token got already used
|
||||
if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.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 := oauth2_provider.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_provider.JWTSigningKey) {
|
||||
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
||||
if err != nil {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.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, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
|
||||
ErrorDescription: errorDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
|
||||
ErrorDescription: "unexpected redirect URI",
|
||||
})
|
||||
return
|
||||
}
|
||||
authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
|
||||
if err != nil || authorizationCode == nil {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
|
||||
ErrorDescription: "client is not authorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
if authorizationCode.IsExpired() {
|
||||
_ = authorizationCode.Invalidate(ctx)
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
|
||||
ErrorDescription: "authorization code expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
// check if code verifier authorizes the client, PKCE support
|
||||
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
|
||||
ErrorDescription: "failed PKCE code challenge",
|
||||
})
|
||||
return
|
||||
}
|
||||
if authorizationCode.RedirectURI != "" && form.RedirectURI != authorizationCode.RedirectURI {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
|
||||
ErrorDescription: "redirect_uri differs from the original authorization request",
|
||||
})
|
||||
return
|
||||
}
|
||||
// check if granted for this application
|
||||
if authorizationCode.Grant.ApplicationID != app.ID {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
|
||||
ErrorDescription: "invalid grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
// remove token from database to deny duplicate usage
|
||||
if err := authorizationCode.Invalidate(ctx); err != nil {
|
||||
errDescription := "cannot process your request"
|
||||
errCode := oauth2_provider.AccessTokenErrorCodeInvalidRequest
|
||||
if errors.Is(err, auth.ErrOAuth2AuthorizationCodeInvalidated) {
|
||||
errDescription = "authorization code already used"
|
||||
errCode = oauth2_provider.AccessTokenErrorCodeInvalidGrant
|
||||
}
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: errCode,
|
||||
ErrorDescription: errDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
resp, tokenErr := oauth2_provider.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 oauth2_provider.AccessTokenError) {
|
||||
ctx.JSON(http.StatusBadRequest, acErr)
|
||||
}
|
||||
|
||||
func handleServerError(ctx *context.Context, state, redirectURI string) {
|
||||
handleAuthorizeError(ctx, AuthorizeError{
|
||||
ErrorCode: ErrorCodeServerError,
|
||||
ErrorDescription: "A server error occurred",
|
||||
State: state,
|
||||
}, redirectURI)
|
||||
}
|
||||
|
||||
func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
|
||||
if redirectURI == "" {
|
||||
log.Warn("Authorization failed: %v", authErr.ErrorDescription)
|
||||
ctx.Data["Error"] = authErr
|
||||
ctx.HTML(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)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/context"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
)
|
||||
|
||||
func oauth2SignInSync(ctx *context.Context, authSourceID int64, u *user_model.User, gothUser goth.User) {
|
||||
oauth2UpdateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
|
||||
|
||||
authSource, err := auth.GetSourceByID(ctx, authSourceID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetSourceByID", err)
|
||||
return
|
||||
}
|
||||
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
|
||||
if !authSource.IsOAuth2() || oauth2Source == nil {
|
||||
ctx.ServerError("oauth2SignInSync", fmt.Errorf("source %s is not an OAuth2 source", gothUser.Provider))
|
||||
return
|
||||
}
|
||||
|
||||
// sync full name
|
||||
fullNameKey := util.IfZero(oauth2Source.FullNameClaimName, "name")
|
||||
fullName, _ := gothUser.RawData[fullNameKey].(string)
|
||||
fullName = util.IfZero(fullName, gothUser.Name)
|
||||
|
||||
// need to update if the user has no full name set
|
||||
shouldUpdateFullName := u.FullName == ""
|
||||
// force to update if the attribute is set
|
||||
shouldUpdateFullName = shouldUpdateFullName || oauth2Source.FullNameClaimName != ""
|
||||
// only update if the full name is different
|
||||
shouldUpdateFullName = shouldUpdateFullName && u.FullName != fullName
|
||||
if shouldUpdateFullName {
|
||||
u.FullName = fullName
|
||||
if err := user_model.UpdateUserCols(ctx, u, "full_name"); err != nil {
|
||||
log.Error("Unable to sync OAuth2 user full name %s: %v", gothUser.Provider, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = oauth2UpdateSSHPubIfNeed(ctx, authSource, &gothUser, u)
|
||||
if err != nil {
|
||||
log.Error("Unable to sync OAuth2 SSH public key %s: %v", gothUser.Provider, err)
|
||||
}
|
||||
}
|
||||
|
||||
func oauth2SyncGetSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) {
|
||||
value, exists := gothUser.RawData[source.SSHPublicKeyClaimName]
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
rawSlice, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid SSH public key value type: %T", value)
|
||||
}
|
||||
|
||||
sshKeys := make([]string, 0, len(rawSlice))
|
||||
for _, v := range rawSlice {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid SSH public key value item type: %T", v)
|
||||
}
|
||||
sshKeys = append(sshKeys, str)
|
||||
}
|
||||
return sshKeys, nil
|
||||
}
|
||||
|
||||
func oauth2UpdateSSHPubIfNeed(ctx *context.Context, authSource *auth.Source, gothUser *goth.User, user *user_model.User) error {
|
||||
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
|
||||
if oauth2Source == nil || oauth2Source.SSHPublicKeyClaimName == "" {
|
||||
return nil
|
||||
}
|
||||
sshKeys, err := oauth2SyncGetSSHKeys(oauth2Source, gothUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys, false) {
|
||||
return nil
|
||||
}
|
||||
return asymkey_service.RewriteAllPublicKeys(ctx)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/services/oauth2_provider"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
|
||||
signingKey, err := oauth2_provider.CreateJWTSigningKey("HS256", make([]byte, 32))
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, signingKey)
|
||||
|
||||
response, terr := oauth2_provider.NewAccessTokenResponse(t.Context(), grant, signingKey, signingKey)
|
||||
assert.Nil(t, terr)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2_provider.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
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, parsedToken.Valid)
|
||||
|
||||
oidcToken, ok := parsedToken.Claims.(*oauth2_provider.OIDCToken)
|
||||
assert.True(t, ok)
|
||||
assert.NotNil(t, oidcToken)
|
||||
|
||||
return oidcToken
|
||||
}
|
||||
|
||||
func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
grants, err := auth.GetOAuth2GrantsByUserID(t.Context(), 3)
|
||||
assert.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(t.Context(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, grants, 1)
|
||||
|
||||
// Scopes: openid profile email
|
||||
oidcToken = createAndParseToken(t, grants[0])
|
||||
assert.Equal(t, user.DisplayName(), oidcToken.Name)
|
||||
assert.Equal(t, user.Name, oidcToken.PreferredUsername)
|
||||
assert.Equal(t, user.HTMLURL(t.Context()), oidcToken.Profile)
|
||||
assert.Equal(t, user.AvatarLink(t.Context()), 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)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/openid"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSignInOpenID templates.TplName = "user/auth/signin_openid"
|
||||
tplConnectOID templates.TplName = "user/auth/signup_openid_connect"
|
||||
tplSignUpOID templates.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 performAutoLogin(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 errors.New("URI not allowed by whitelist")
|
||||
}
|
||||
|
||||
// A blacklist match expliclty forbids
|
||||
for _, pat := range setting.Service.OpenIDBlacklist {
|
||||
if pat.MatchString(uri) {
|
||||
return errors.New("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.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &form)
|
||||
return
|
||||
}
|
||||
form.Openid = id
|
||||
|
||||
log.Trace("OpenID uri: " + id)
|
||||
|
||||
err = allowedOpenIDURI(id)
|
||||
if err != nil {
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated("Unable to find OpenID provider in "+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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
|
||||
Openid: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
values, err := url.ParseQuery(parsedURL.RawQuery)
|
||||
if err != nil {
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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")
|
||||
}
|
||||
}
|
||||
|
||||
func prepareConnectOpenIDPageData(ctx *context.Context) (oid string) {
|
||||
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["OpenID"] = oid
|
||||
prepareCommonAuthPageData(ctx, CommonAuthOptions{EnableCaptcha: false})
|
||||
return oid
|
||||
}
|
||||
|
||||
// ConnectOpenID shows a form to connect an OpenID URI to an existing account
|
||||
func ConnectOpenID(ctx *context.Context) {
|
||||
oid := prepareConnectOpenIDPageData(ctx)
|
||||
if oid == "" {
|
||||
return
|
||||
}
|
||||
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 := prepareConnectOpenIDPageData(ctx)
|
||||
if oid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
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.RenderWithErrDeprecated(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)
|
||||
}
|
||||
|
||||
func prepareRegisterOpenIDPageData(ctx *context.Context) (oid string) {
|
||||
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["OpenID"] = oid
|
||||
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
||||
EnableCaptcha: setting.Service.EnableCaptcha,
|
||||
})
|
||||
return oid
|
||||
}
|
||||
|
||||
// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI
|
||||
func RegisterOpenID(ctx *context.Context) {
|
||||
oid := prepareRegisterOpenIDPageData(ctx)
|
||||
if oid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
oid := prepareRegisterOpenIDPageData(ctx)
|
||||
if oid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.SignUpOpenIDForm)
|
||||
|
||||
if setting.Service.AllowOnlyInternalRegistration {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if setting.Service.EnableCaptcha {
|
||||
if err := ctx.Req.ParseForm(); err != nil {
|
||||
ctx.ServerError("", err)
|
||||
return
|
||||
}
|
||||
context.VerifyCaptcha(ctx, tplSignUpOID, form)
|
||||
}
|
||||
|
||||
length := max(setting.MinPasswordLength, 256)
|
||||
password := util.CryptoRandomString(int64(length))
|
||||
|
||||
u := &user_model.User{
|
||||
Name: form.UserName,
|
||||
Email: form.Email,
|
||||
Passwd: password,
|
||||
}
|
||||
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil) {
|
||||
// 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.RenderWithErrDeprecated(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)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/mailer"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
var (
|
||||
// tplMustChangePassword template for updating a user's password
|
||||
tplMustChangePassword templates.TplName = "user/auth/change_passwd"
|
||||
tplForgotPassword templates.TplName = "user/auth/forgot_passwd"
|
||||
tplResetPassword templates.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(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.RenderWithErrDeprecated(ctx.Tr("auth.non_local_account"), tplForgotPassword, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
|
||||
ctx.Data["ResendLimited"] = true
|
||||
ctx.HTML(http.StatusOK, tplForgotPassword)
|
||||
return
|
||||
}
|
||||
|
||||
mailer.SendResetPasswordMail(u)
|
||||
|
||||
if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
|
||||
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
||||
}
|
||||
|
||||
ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale)
|
||||
ctx.Data["IsResetSent"] = true
|
||||
ctx.HTML(http.StatusOK, tplForgotPassword)
|
||||
}
|
||||
|
||||
func commonResetPassword(ctx *context.Context) (*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", setting.AppSubURL+"/user/forgot_password"), true)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fail early, don't frustrate the user
|
||||
u := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, code)
|
||||
if u == nil {
|
||||
ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", setting.AppSubURL+"/user/forgot_password"), true)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
twofa, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
||||
if err != nil {
|
||||
if !auth.IsErrTwoFactorNotEnrolled(err) {
|
||||
ctx.HTTPError(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)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplResetPassword)
|
||||
}
|
||||
|
||||
// ResetPasswdPost response from account recovery request
|
||||
func ResetPasswdPost(ctx *context.Context) {
|
||||
u, twofa := commonResetPassword(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if u == nil {
|
||||
// Flash error has been set
|
||||
ctx.HTML(http.StatusOK, tplResetPassword)
|
||||
return
|
||||
}
|
||||
|
||||
// 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.RenderWithErrDeprecated(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.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
|
||||
return
|
||||
}
|
||||
if !ok || twofa.LastUsedPasscode == passcode {
|
||||
ctx.Data["IsResetForm"] = true
|
||||
ctx.Data["Err_Passcode"] = true
|
||||
ctx.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil)
|
||||
case errors.Is(err, password.ErrComplexity):
|
||||
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil)
|
||||
case errors.Is(err, password.ErrIsPwned):
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplResetPassword, nil)
|
||||
case password.IsErrIsPwnedRequest(err):
|
||||
ctx.RenderWithErrDeprecated(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)
|
||||
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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form)
|
||||
case errors.Is(err, password.ErrComplexity):
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form)
|
||||
case errors.Is(err, password.ErrIsPwned):
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplMustChangePassword, &form)
|
||||
case password.IsErrIsPwnedRequest(err):
|
||||
ctx.Data["Err_Password"] = true
|
||||
ctx.RenderWithErrDeprecated(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)
|
||||
|
||||
redirectAfterAuth(ctx)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
wa "gitea.dev/modules/auth/webauthn"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
var tplWebAuthn templates.TplName = "user/auth/webauthn"
|
||||
|
||||
// WebAuthn shows the WebAuthn login page
|
||||
func WebAuthn(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("twofa")
|
||||
|
||||
if performAutoLogin(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)
|
||||
}
|
||||
|
||||
// WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser
|
||||
func WebAuthnPasskeyAssertion(ctx *context.Context) {
|
||||
if !setting.Service.EnablePasskeyAuth {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctx.Session.Set("webauthnPasskeyAssertion", sessionData); err != nil {
|
||||
ctx.ServerError("Session.Set", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, assertion)
|
||||
}
|
||||
|
||||
// WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey
|
||||
func WebAuthnPasskeyLogin(ctx *context.Context) {
|
||||
if !setting.Service.EnablePasskeyAuth {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData)
|
||||
if !okData || sessionData == nil {
|
||||
ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session"))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = ctx.Session.Delete("webauthnPasskeyAssertion")
|
||||
}()
|
||||
|
||||
// Validate the parsed response.
|
||||
|
||||
// ParseCredentialRequestResponse+ValidateDiscoverableLogin equals to FinishDiscoverableLogin, but we need to ParseCredentialRequestResponse first to get flags
|
||||
var user *user_model.User
|
||||
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
|
||||
}
|
||||
cred, err := wa.WebAuthn.ValidateDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
userID, n := binary.Varint(userHandle)
|
||||
if n <= 0 {
|
||||
return nil, errors.New("invalid rawID")
|
||||
}
|
||||
|
||||
var err error
|
||||
user, err = user_model.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags), nil
|
||||
}, *sessionData, parsedResponse)
|
||||
if err != nil {
|
||||
// Failed authentication attempt.
|
||||
log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err)
|
||||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !cred.Flags.UserPresent {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
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
|
||||
}
|
||||
|
||||
// Success! Get the credential and update the sign count with the new value we received.
|
||||
dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
|
||||
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 := linkAccountFromContext(ctx, user); err != nil {
|
||||
ctx.ServerError("LinkAccountFromStore", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
remember := false // TODO: implement remember me
|
||||
handleSignInFull(ctx, user, remember)
|
||||
ctx.JSONRedirect(consumeAuthRedirectLink(ctx))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
|
||||
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser)
|
||||
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
|
||||
}
|
||||
|
||||
// Validate the parsed response.
|
||||
webAuthnUser := wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags)
|
||||
cred, err := wa.WebAuthn.ValidateLogin(webAuthnUser, *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
|
||||
}
|
||||
|
||||
// Success! Get the credential and update the sign count with the new value we received.
|
||||
dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
|
||||
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 := linkAccountFromContext(ctx, user); err != nil {
|
||||
ctx.ServerError("LinkAccountFromStore", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||
handleSignInFull(ctx, user, remember)
|
||||
_ = ctx.Session.Delete("twofaUid")
|
||||
ctx.JSONRedirect(consumeAuthRedirectLink(ctx))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/assetfs"
|
||||
"gitea.dev/modules/avatar"
|
||||
"gitea.dev/modules/httpcache"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web/routing"
|
||||
)
|
||||
|
||||
func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
funcInfo := routing.GetFuncInfo(avatarStorageHandler, prefix)
|
||||
exeModTime := assetfs.GetExecutableModTime()
|
||||
fallbackEtag := fmt.Sprintf(`"avatar-%s"`, exeModTime.Format("20060102150405"))
|
||||
|
||||
handleError := func(w http.ResponseWriter, req *http.Request, avatarPath string, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, util.ErrNotExist) {
|
||||
// if avatar doesn't exist, generate a random one and serve it with proper cache control headers
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
if !httpcache.HandleGenericETagPublicCache(req, w, fallbackEtag, &exeModTime) {
|
||||
if req.Method == http.MethodGet {
|
||||
img := avatar.RandomImageWithSize(96, []byte(avatarPath))
|
||||
_ = png.Encode(w, img)
|
||||
} // else: for HEAD request, just return the headers without body
|
||||
}
|
||||
} else {
|
||||
// for internal errors, log the error and return 500
|
||||
log.Error("Error when serving avatar %s: %s", req.URL.Path, err)
|
||||
http.Error(w, "unable to serve avatar image", http.StatusInternalServerError)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
defer routing.RecordFuncInfo(req.Context(), funcInfo)()
|
||||
|
||||
avatarPath, ok := strings.CutPrefix(req.URL.Path, "/"+prefix+"/")
|
||||
if !ok {
|
||||
http.Error(w, "invalid avatar path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
avatarPath = util.PathJoinRelX(avatarPath)
|
||||
if avatarPath == "" || avatarPath == "." {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if storageSetting.ServeDirect() {
|
||||
// Old logic: no check for existence by Stat, so old code's "errors.Is(err, os.ErrNotExist)" didn't work.
|
||||
// So in theory, it doesn't work with the non-existing avatar fallback, it just gets the URL and redirects to it.
|
||||
// Checking "stat" requires one more request to the storage, which is inefficient.
|
||||
// Workaround: disable "SERVE_DIRECT". Leave the problem to the future.
|
||||
u, err := objStore.ServeDirectURL(avatarPath, path.Base(avatarPath), req.Method, nil)
|
||||
if handleError(w, req, avatarPath, err) {
|
||||
return
|
||||
}
|
||||
http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
fr, err := objStore.Open(avatarPath)
|
||||
if handleError(w, req, avatarPath, err) {
|
||||
return
|
||||
}
|
||||
defer fr.Close()
|
||||
|
||||
fi, err := fr.Stat()
|
||||
if handleError(w, req, avatarPath, err) {
|
||||
return
|
||||
}
|
||||
|
||||
httpcache.SetCacheControlInHeader(w.Header(), httpcache.CacheControlForPublicStatic())
|
||||
http.ServeContent(w, req, path.Base(avatarPath), fi.ModTime(), fr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package devtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/badge"
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/indexer/code"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/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 !strings.HasPrefix(subName, "devtest-") {
|
||||
subNames = append(subNames, subName)
|
||||
}
|
||||
}
|
||||
ctx.Data["SubNames"] = subNames
|
||||
ctx.HTML(http.StatusOK, "devtest/devtest-list")
|
||||
}
|
||||
|
||||
func FetchActionTest(ctx *context.Context) {
|
||||
_ = ctx.Req.ParseForm()
|
||||
ctx.Flash.Info("fetch action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" +
|
||||
"Form: " + ctx.Req.Form.Encode() + "\n" +
|
||||
"PostForm: " + ctx.Req.PostForm.Encode(),
|
||||
)
|
||||
time.Sleep(2 * time.Second)
|
||||
ctx.JSONRedirect("")
|
||||
}
|
||||
|
||||
func prepareMockDataGiteaUI(_ *context.Context) {}
|
||||
|
||||
func prepareMockDataBadgeCommitSign(ctx *context.Context) {
|
||||
var commits []*asymkey.SignCommit
|
||||
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}})
|
||||
mockUser := mockUsers[0]
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
Verification: &asymkey.CommitVerification{},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
Verification: &asymkey.CommitVerification{
|
||||
Verified: true,
|
||||
Reason: "name / key-id",
|
||||
SigningUser: mockUser,
|
||||
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
|
||||
TrustStatus: "trusted",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
Verification: &asymkey.CommitVerification{
|
||||
Verified: true,
|
||||
Reason: "name / key-id",
|
||||
SigningUser: mockUser,
|
||||
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
|
||||
TrustStatus: "untrusted",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
Verification: &asymkey.CommitVerification{
|
||||
Verified: true,
|
||||
Reason: "name / key-id",
|
||||
SigningUser: mockUser,
|
||||
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
|
||||
TrustStatus: "other(unmatch)",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
Verification: &asymkey.CommitVerification{
|
||||
Warning: true,
|
||||
Reason: "gpg.error",
|
||||
SigningEmail: "test@example.com",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
|
||||
ctx.Data["MockCommits"] = commits
|
||||
}
|
||||
|
||||
func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
|
||||
fontFamilyNames := strings.Split(badge.DefaultFontFamily, ",")
|
||||
selectedFontFamilyName := ctx.FormString("font", fontFamilyNames[0])
|
||||
selectedStyle := ctx.FormString("style", badge.DefaultStyle)
|
||||
var badges []badge.Badge
|
||||
badges = append(badges, badge.GenerateBadge("啊啊啊啊啊啊啊啊啊啊啊啊", "🌞🌞🌞🌞🌞", "green"))
|
||||
for r := range rune(256) {
|
||||
if unicode.IsPrint(r) {
|
||||
s := strings.Repeat(string(r), 15)
|
||||
badges = append(badges, badge.GenerateBadge(s, util.TruncateRunes(s, 7), "green"))
|
||||
}
|
||||
}
|
||||
|
||||
var badgeSVGs []template.HTML
|
||||
for i, b := range badges {
|
||||
b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-"
|
||||
b.FontFamily = selectedFontFamilyName
|
||||
var h template.HTML
|
||||
var err error
|
||||
switch selectedStyle {
|
||||
case badge.StyleFlat:
|
||||
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat", map[string]any{"Badge": b})
|
||||
case badge.StyleFlatSquare:
|
||||
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat-square", map[string]any{"Badge": b})
|
||||
default:
|
||||
err = fmt.Errorf("unknown badge style: %s", selectedStyle)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.ServerError("RenderToHTML", err)
|
||||
return
|
||||
}
|
||||
badgeSVGs = append(badgeSVGs, h)
|
||||
}
|
||||
ctx.Data["BadgeSVGs"] = badgeSVGs
|
||||
ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames
|
||||
ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName
|
||||
ctx.Data["BadgeStyles"] = badge.GlobalVars().AllStyles
|
||||
ctx.Data["SelectedStyle"] = selectedStyle
|
||||
}
|
||||
|
||||
func prepareMockDataRelativeTime(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["TimePast3m"] = now.Add(-3 * time.Minute)
|
||||
ctx.Data["TimePast1h"] = now.Add(-1 * time.Hour)
|
||||
ctx.Data["TimePast3h"] = now.Add(-3 * time.Hour)
|
||||
ctx.Data["TimePast1d"] = now.Add(-24 * time.Hour)
|
||||
ctx.Data["TimePast2d"] = now.Add(-2 * 24 * time.Hour)
|
||||
ctx.Data["TimePast3d"] = now.Add(-3 * 24 * time.Hour)
|
||||
ctx.Data["TimePast26h"] = now.Add(-26 * time.Hour)
|
||||
ctx.Data["TimePast40d"] = now.Add(-40 * 24 * time.Hour)
|
||||
ctx.Data["TimePast60d"] = now.Add(-60 * 24 * time.Hour)
|
||||
ctx.Data["TimePast1y"] = now.Add(-366 * 24 * time.Hour)
|
||||
ctx.Data["TimeFuture1h"] = now.Add(1 * time.Hour)
|
||||
ctx.Data["TimeFuture3h"] = now.Add(3 * time.Hour)
|
||||
ctx.Data["TimeFuture3d"] = now.Add(3 * 24 * time.Hour)
|
||||
ctx.Data["TimeFuture1y"] = now.Add(366 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
func prepareMockData(ctx *context.Context) {
|
||||
switch ctx.Req.URL.Path {
|
||||
case "/devtest/gitea-ui":
|
||||
prepareMockDataGiteaUI(ctx)
|
||||
case "/devtest/badge-commit-sign":
|
||||
prepareMockDataBadgeCommitSign(ctx)
|
||||
case "/devtest/badge-actions-svg":
|
||||
prepareMockDataBadgeActionsSvg(ctx)
|
||||
case "/devtest/relative-time":
|
||||
prepareMockDataRelativeTime(ctx)
|
||||
case "/devtest/toast-and-message":
|
||||
prepareMockDataToastAndMessage(ctx)
|
||||
case "/devtest/unicode-escape":
|
||||
prepareMockDataUnicodeEscape(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareMockDataToastAndMessage(ctx *context.Context) {
|
||||
msgWithDetails, _ := ctx.RenderToHTML("base/alert_details", map[string]any{
|
||||
"Message": "message with details <script>escape xss</script>",
|
||||
"Summary": "summary with details",
|
||||
"Details": "details line 1\n details line 2\n details line 3",
|
||||
})
|
||||
msgWithSummary, _ := ctx.RenderToHTML("base/alert_details", map[string]any{
|
||||
"Message": "message with summary <script>escape xss</script>",
|
||||
"Summary": "summary only",
|
||||
})
|
||||
|
||||
ctx.Flash.ErrorMsg = string(msgWithDetails)
|
||||
ctx.Flash.WarningMsg = string(msgWithSummary)
|
||||
ctx.Flash.InfoMsg = "a long message with line break\nthe second line <script>removed xss</script>"
|
||||
ctx.Flash.SuccessMsg = "single line message <script>removed xss</script>"
|
||||
ctx.Data["Flash"] = ctx.Flash
|
||||
}
|
||||
|
||||
func prepareMockDataUnicodeEscape(ctx *context.Context) {
|
||||
content := "// demo code\n"
|
||||
content += "if accessLevel != \"user\u202E \u2066// Check if admin (invisible char)\u2069 \u2066\" { }\n"
|
||||
content += "if O𝐾 { } // ambiguous char\n"
|
||||
content += "if O𝐾 && accessLevel != \"user\u202E \u2066// ambiguous char + invisible char\u2069 \u2066\" { }\n"
|
||||
content += "str := `\xef` // broken char\n"
|
||||
content += "str := `\x00 \x19 \x7f` // control char\n"
|
||||
|
||||
lineNums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
|
||||
|
||||
highlightLines := code.HighlightSearchResultCode("demo.go", "", lineNums, content)
|
||||
escapeStatus := &charset.EscapeStatus{}
|
||||
lineEscapeStatus := make([]*charset.EscapeStatus, len(highlightLines))
|
||||
for i, hl := range highlightLines {
|
||||
lineEscapeStatus[i], hl.FormattedContent = charset.EscapeControlHTML(hl.FormattedContent, ctx.Locale)
|
||||
escapeStatus = escapeStatus.Or(lineEscapeStatus[i])
|
||||
}
|
||||
ctx.Data["HighlightLines"] = highlightLines
|
||||
ctx.Data["EscapeStatus"] = escapeStatus
|
||||
ctx.Data["LineEscapeStatus"] = lineEscapeStatus
|
||||
}
|
||||
|
||||
func TmplCommon(ctx *context.Context) {
|
||||
prepareMockData(ctx)
|
||||
if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") {
|
||||
ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
|
||||
"Form: "+ctx.Req.Form.Encode()+"\n"+
|
||||
"PostForm: "+ctx.Req.PostForm.Encode(),
|
||||
true,
|
||||
)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
ctx.HTML(http.StatusOK, templates.TplName("devtest"+path.Clean("/"+ctx.PathParam("sub"))))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package devtest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/mailer"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func MailPreviewRender(ctx *context.Context) {
|
||||
tmplName := ctx.PathParam("*")
|
||||
mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".devtest.yml")
|
||||
mockData := map[string]any{}
|
||||
if err == nil {
|
||||
err = yaml.Unmarshal(mockDataContent, &mockData)
|
||||
if err != nil {
|
||||
http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
mockData["locale"] = ctx.Locale
|
||||
err = mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(ctx.Resp, tmplName, mockData)
|
||||
if err != nil {
|
||||
_, _ = ctx.Resp.Write([]byte(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func prepareMailPreviewRender(ctx *context.Context, tmplName string) {
|
||||
tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName)
|
||||
// FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates
|
||||
subject := "(default subject)"
|
||||
if tmplSubject != nil {
|
||||
var buf strings.Builder
|
||||
err := tmplSubject.Execute(&buf, nil)
|
||||
if err != nil {
|
||||
subject = "ERROR: " + err.Error()
|
||||
} else {
|
||||
subject = util.IfZero(buf.String(), subject)
|
||||
}
|
||||
}
|
||||
ctx.Data["RenderMailSubject"] = subject
|
||||
ctx.Data["RenderMailTemplateName"] = tmplName
|
||||
}
|
||||
|
||||
func MailPreview(ctx *context.Context) {
|
||||
ctx.Data["MailTemplateNames"] = mailer.LoadedTemplates().TemplateNames
|
||||
tmplName := ctx.FormString("tmpl")
|
||||
if tmplName != "" {
|
||||
prepareMailPreviewRender(ctx, tmplName)
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "devtest/mail-preview")
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package devtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
mathRand "math/rand/v2"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/web/repo/actions"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
type generateMockStepsLogOptions struct {
|
||||
mockCountFirst int
|
||||
mockCountGeneral int
|
||||
groupRepeat int
|
||||
}
|
||||
|
||||
func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOptions) (stepsLog []*actions.ViewStepLog) {
|
||||
var mockedLogs []string
|
||||
mockedLogs = append(mockedLogs, "::group::test group for: step={step}, cursor={cursor}")
|
||||
mockedLogs = append(mockedLogs, slices.Repeat([]string{"in group msg for: step={step}, cursor={cursor}"}, opts.groupRepeat)...)
|
||||
mockedLogs = append(mockedLogs, "::endgroup::")
|
||||
mockedLogs = append(mockedLogs,
|
||||
"message for: step={step}, cursor={cursor}",
|
||||
"message for: step={step}, cursor={cursor}",
|
||||
"##[group]test group for: step={step}, cursor={cursor}",
|
||||
"in group msg for: step={step}, cursor={cursor}",
|
||||
"##[endgroup]",
|
||||
"::error::mock error for: step={step}, cursor={cursor}",
|
||||
"::warning::mock warning for: step={step}, cursor={cursor}",
|
||||
"::notice::mock notice for: step={step}, cursor={cursor}",
|
||||
"::debug::mock debug for: step={step}, cursor={cursor}",
|
||||
)
|
||||
// usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
|
||||
cur := logCur.Cursor
|
||||
// for the first batch, return as many as possible to test the auto-expand and auto-scroll
|
||||
mockCount := util.Iif(logCur.Cursor == 0, opts.mockCountFirst, opts.mockCountGeneral)
|
||||
for range mockCount {
|
||||
logStr := mockedLogs[int(cur)%len(mockedLogs)]
|
||||
cur++
|
||||
logStr = strings.ReplaceAll(logStr, "{step}", strconv.Itoa(logCur.Step))
|
||||
logStr = strings.ReplaceAll(logStr, "{cursor}", strconv.FormatInt(cur, 10))
|
||||
stepsLog = append(stepsLog, &actions.ViewStepLog{
|
||||
Step: logCur.Step,
|
||||
Cursor: cur,
|
||||
Started: time.Now().Unix() - 1,
|
||||
Lines: []*actions.ViewStepLogLine{
|
||||
{Index: cur, Message: logStr, Timestamp: float64(time.Now().UnixNano()) / float64(time.Second)},
|
||||
},
|
||||
})
|
||||
}
|
||||
return stepsLog
|
||||
}
|
||||
|
||||
func MockActionsView(ctx *context.Context) {
|
||||
if runID := ctx.PathParamInt64("run"); runID == 0 {
|
||||
ctx.Redirect("/repo-action-view/runs/10")
|
||||
return
|
||||
}
|
||||
ctx.Data["JobID"] = ctx.PathParamInt64("job")
|
||||
ctx.Data["ActionsViewURL"] = ctx.Req.URL.Path
|
||||
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
|
||||
}
|
||||
|
||||
func MockActionsRunsJobs(ctx *context.Context) {
|
||||
runID := ctx.PathParamInt64("run")
|
||||
attemptID := ctx.PathParamInt64("attempt")
|
||||
|
||||
alignTime := func(v, unit int64) int64 {
|
||||
return (v + unit) / unit * unit
|
||||
}
|
||||
resp := &actions.ViewResponse{}
|
||||
resp.State.Run.RepoID = 12345
|
||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
|
||||
resp.State.Run.CanDeleteArtifact = true
|
||||
resp.State.Run.WorkflowID = "workflow-id"
|
||||
resp.State.Run.WorkflowLink = "./workflow-link"
|
||||
resp.State.Run.TriggerEvent = "push"
|
||||
resp.State.Run.Commit = actions.ViewCommit{
|
||||
ShortSha: "ccccdddd",
|
||||
Link: "./commit-link",
|
||||
Pusher: actions.ViewUser{
|
||||
DisplayName: "pusher user",
|
||||
Link: "./pusher-link",
|
||||
},
|
||||
Branch: actions.ViewBranch{
|
||||
Name: "commit-branch",
|
||||
Link: "./branch-link",
|
||||
IsDeleted: false,
|
||||
},
|
||||
}
|
||||
now := time.Now()
|
||||
currentAttemptNum := int64(1)
|
||||
if attemptID > 0 {
|
||||
currentAttemptNum = attemptID
|
||||
}
|
||||
user2 := &user_model.User{Name: "user2"}
|
||||
user3 := &user_model.User{Name: "user3"}
|
||||
attempts := []*actions_model.ActionRunAttempt{{
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Created: timeutil.TimeStamp(now.Add(-time.Hour).Unix()),
|
||||
TriggerUserID: 2,
|
||||
TriggerUser: user2,
|
||||
}}
|
||||
if runID == 10 {
|
||||
attempts = []*actions_model.ActionRunAttempt{
|
||||
{
|
||||
Attempt: 3,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Created: timeutil.TimeStamp(alignTime(now.Add(-time.Hour).Unix(), 3600)),
|
||||
TriggerUserID: 2,
|
||||
TriggerUser: user2,
|
||||
},
|
||||
{
|
||||
Attempt: 2,
|
||||
Status: actions_model.StatusFailure,
|
||||
Created: timeutil.TimeStamp(alignTime(now.Add(-2*time.Hour).Unix(), 3600)),
|
||||
TriggerUserID: 1,
|
||||
TriggerUser: user3,
|
||||
},
|
||||
{
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Created: timeutil.TimeStamp(alignTime(now.Add(-3*time.Hour).Unix(), 3600)),
|
||||
TriggerUserID: 2,
|
||||
TriggerUser: user2,
|
||||
},
|
||||
}
|
||||
if attemptID == 0 {
|
||||
currentAttemptNum = 3
|
||||
}
|
||||
}
|
||||
|
||||
latestAttempt := attempts[0]
|
||||
resp.State.Run.RunAttempt = currentAttemptNum
|
||||
resp.State.Run.Done = latestAttempt.Status.IsDone()
|
||||
resp.State.Run.Status = latestAttempt.Status.String()
|
||||
resp.State.Run.Duration = "1h 23m 45s"
|
||||
resp.State.Run.TriggeredAt = latestAttempt.Created.AsTime().Unix()
|
||||
resp.State.Run.ViewLink = resp.State.Run.Link
|
||||
for _, attempt := range attempts {
|
||||
link := resp.State.Run.Link
|
||||
if attempt.Attempt != latestAttempt.Attempt {
|
||||
link = fmt.Sprintf("%s/attempts/%d", resp.State.Run.Link, attempt.Attempt)
|
||||
}
|
||||
current := attempt.Attempt == currentAttemptNum
|
||||
if current {
|
||||
resp.State.Run.Status = attempt.Status.String()
|
||||
resp.State.Run.Done = attempt.Status.IsDone()
|
||||
resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix()
|
||||
if attempt.Attempt != latestAttempt.Attempt {
|
||||
resp.State.Run.ViewLink = link
|
||||
}
|
||||
}
|
||||
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{
|
||||
Attempt: attempt.Attempt,
|
||||
Status: attempt.Status.String(),
|
||||
Done: attempt.Status.IsDone(),
|
||||
Link: link,
|
||||
Current: current,
|
||||
Latest: attempt.Attempt == latestAttempt.Attempt,
|
||||
TriggeredAt: attempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: attempt.TriggerUser.HomeLink(),
|
||||
})
|
||||
}
|
||||
isLatestAttempt := currentAttemptNum == latestAttempt.Attempt
|
||||
resp.State.Run.CanCancel = runID == 10 && isLatestAttempt
|
||||
resp.State.Run.CanApprove = runID == 20 && isLatestAttempt
|
||||
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
|
||||
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
|
||||
|
||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||
Name: "artifact-a",
|
||||
Size: 100 * 1024,
|
||||
Status: "expired",
|
||||
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
|
||||
})
|
||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||
Name: "artifact-b",
|
||||
Size: 1024 * 1024,
|
||||
Status: "completed",
|
||||
ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600),
|
||||
})
|
||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
|
||||
Size: 100 * 1024,
|
||||
Status: "expired",
|
||||
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
|
||||
})
|
||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||
Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
|
||||
Size: 1024 * 1024,
|
||||
Status: "completed",
|
||||
ExpiresUnix: 0,
|
||||
})
|
||||
|
||||
jobLink := func(jobID int64) string {
|
||||
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
|
||||
}
|
||||
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID * 10,
|
||||
Link: jobLink(runID * 10),
|
||||
JobID: "job-100",
|
||||
Name: "job 100 (testsubname)",
|
||||
Status: actions_model.StatusRunning.String(),
|
||||
CanRerun: true,
|
||||
Duration: "1h23m45s",
|
||||
})
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID*10 + 1,
|
||||
Link: jobLink(runID*10 + 1),
|
||||
JobID: "job-101",
|
||||
Name: "job 101",
|
||||
Status: actions_model.StatusWaiting.String(),
|
||||
CanRerun: false,
|
||||
Duration: "2h",
|
||||
Needs: []string{"job-100"},
|
||||
})
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID*10 + 2,
|
||||
Link: jobLink(runID*10 + 2),
|
||||
JobID: "job-102",
|
||||
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
|
||||
Status: actions_model.StatusFailure.String(),
|
||||
CanRerun: false,
|
||||
Duration: "3h",
|
||||
Needs: []string{"job-100", "job-101"},
|
||||
})
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID*10 + 3,
|
||||
Link: jobLink(runID*10 + 3),
|
||||
JobID: "job-103",
|
||||
Name: "job 103",
|
||||
Status: actions_model.StatusCancelled.String(),
|
||||
CanRerun: false,
|
||||
Duration: "2m",
|
||||
Needs: []string{"job-100"},
|
||||
})
|
||||
|
||||
// add more jobs to a run for UI testing
|
||||
if resp.State.Run.CanCancel {
|
||||
for i := range 10 {
|
||||
jobID := runID*1000 + int64(i)
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: jobID,
|
||||
Link: jobLink(jobID),
|
||||
JobID: "job-dup-test-" + strconv.Itoa(i),
|
||||
Name: "job dup test " + strconv.Itoa(i),
|
||||
Status: actions_model.StatusSuccess.String(),
|
||||
CanRerun: false,
|
||||
Duration: "2m",
|
||||
Needs: []string{"job-103", "job-101", "job-100"},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if runID == 40 {
|
||||
// Reusable workflow caller demo: same-repo caller (with a nested same-repo caller inside),
|
||||
// alongside a flat cross-repo caller.
|
||||
// Layout:
|
||||
// prepare (regular, top-level)
|
||||
// local_caller (caller, same-repo, expanded)
|
||||
// ├ lib_step (regular)
|
||||
// └ inner_caller (caller, same-repo nested, expanded)
|
||||
// └ deep_job (regular)
|
||||
// cross_caller (caller, cross-repo, expanded)
|
||||
// └ external_job (regular)
|
||||
// final (regular, needs local_caller + cross_caller)
|
||||
const (
|
||||
prepareID = int64(400)
|
||||
localCallerID = int64(401)
|
||||
libStepID = int64(402)
|
||||
innerCallerID = int64(403)
|
||||
deepJobID = int64(404)
|
||||
crossCallerID = int64(405)
|
||||
externalJobID = int64(406)
|
||||
finalID = int64(407)
|
||||
)
|
||||
|
||||
resp.State.Run.Jobs = []*actions.ViewJob{
|
||||
{
|
||||
ID: prepareID, Link: jobLink(prepareID), JobID: "prepare", Name: "prepare",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "30s",
|
||||
},
|
||||
{
|
||||
ID: localCallerID, Link: jobLink(localCallerID), JobID: "local_caller", Name: "local caller",
|
||||
Status: actions_model.StatusRunning.String(), Duration: "5m",
|
||||
Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/lib.yml",
|
||||
},
|
||||
{
|
||||
ID: libStepID, Link: jobLink(libStepID), JobID: "lib_step", Name: "lib step",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m",
|
||||
ParentJobID: localCallerID,
|
||||
},
|
||||
{
|
||||
ID: innerCallerID, Link: jobLink(innerCallerID), JobID: "inner_caller", Name: "inner caller (nested)",
|
||||
Status: actions_model.StatusRunning.String(), Duration: "4m",
|
||||
ParentJobID: localCallerID,
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/inner.yml",
|
||||
},
|
||||
{
|
||||
ID: deepJobID, Link: jobLink(deepJobID), JobID: "deep_job", Name: "deep job",
|
||||
Status: actions_model.StatusRunning.String(), Duration: "2m",
|
||||
ParentJobID: innerCallerID,
|
||||
},
|
||||
{
|
||||
ID: crossCallerID, Link: jobLink(crossCallerID), JobID: "cross_caller", Name: "cross-repo caller",
|
||||
Status: actions_model.StatusWaiting.String(), Duration: "0s",
|
||||
Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "user2/lib-repo/.gitea/workflows/external.yml@main",
|
||||
},
|
||||
{
|
||||
ID: externalJobID, Link: jobLink(externalJobID), JobID: "external_job", Name: "external job",
|
||||
Status: actions_model.StatusWaiting.String(), Duration: "0s",
|
||||
ParentJobID: crossCallerID,
|
||||
},
|
||||
{
|
||||
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
|
||||
Status: actions_model.StatusBlocked.String(), Duration: "0s",
|
||||
Needs: []string{"local_caller", "cross_caller"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fillViewRunResponseCurrentJob(ctx, resp)
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewResponse) {
|
||||
jobID := ctx.PathParamInt64("job")
|
||||
if jobID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, job := range resp.State.Run.Jobs {
|
||||
if job.ID == jobID {
|
||||
resp.State.CurrentJob.Title = job.Name
|
||||
resp.State.CurrentJob.Detail = job.Status
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
req := web.GetForm(ctx).(*actions.ViewRequest)
|
||||
var mockLogOptions []generateMockStepsLogOptions
|
||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||
Summary: "step 0 (mock slow)",
|
||||
Duration: time.Hour.String(),
|
||||
Status: actions_model.StatusRunning.String(),
|
||||
})
|
||||
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 1, groupRepeat: 3})
|
||||
|
||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||
Summary: "step 1 (mock fast)",
|
||||
Duration: time.Hour.String(),
|
||||
Status: actions_model.StatusRunning.String(),
|
||||
})
|
||||
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 20})
|
||||
|
||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||
Summary: "step 2 (mock error)",
|
||||
Duration: time.Hour.String(),
|
||||
Status: actions_model.StatusRunning.String(),
|
||||
})
|
||||
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
|
||||
|
||||
if len(req.LogCursors) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
resp.Logs.StepsLog = []*actions.ViewStepLog{}
|
||||
doSlowResponse := false
|
||||
doErrorResponse := false
|
||||
for _, logCur := range req.LogCursors {
|
||||
if !logCur.Expanded {
|
||||
continue
|
||||
}
|
||||
doSlowResponse = doSlowResponse || logCur.Step == 0
|
||||
doErrorResponse = doErrorResponse || logCur.Step == 2
|
||||
resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur, mockLogOptions[logCur.Step])...)
|
||||
}
|
||||
if doErrorResponse {
|
||||
if mathRand.Float64() > 0.5 {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "devtest mock error response")
|
||||
return
|
||||
}
|
||||
}
|
||||
if doSlowResponse {
|
||||
time.Sleep(time.Duration(3000) * time.Millisecond)
|
||||
} else {
|
||||
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/eventsource"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/routers/web/auth"
|
||||
"gitea.dev/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()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package explore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
code_indexer "gitea.dev/modules/indexer/code"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplExploreCode explore code page template
|
||||
tplExploreCode templates.TplName = "explore/code"
|
||||
)
|
||||
|
||||
// Code render explore code page
|
||||
func Code(ctx *context.Context) {
|
||||
if !setting.Indexer.RepoIndexerEnabled || setting.Service.Explore.DisableCodePage {
|
||||
ctx.Redirect(setting.AppSubURL + "/explore")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
|
||||
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
|
||||
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
|
||||
ctx.Data["Title"] = ctx.Tr("explore_title")
|
||||
ctx.Data["PageIsExplore"] = true
|
||||
ctx.Data["PageIsExploreCode"] = true
|
||||
ctx.Data["PageIsViewCode"] = true
|
||||
|
||||
prepareSearch := common.PrepareCodeSearch(ctx)
|
||||
if prepareSearch.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 int64
|
||||
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: prepareSearch.Keyword,
|
||||
SearchMode: prepareSearch.SearchMode,
|
||||
Language: prepareSearch.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 {
|
||||
if !slices.Contains(loadRepoIDs, result.RepoID) {
|
||||
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.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, tplExploreCode)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package explore
|
||||
|
||||
import (
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// Organizations render explore organizations page
|
||||
func Organizations(ctx *context.Context) {
|
||||
if setting.Service.Explore.DisableOrganizationsPage {
|
||||
ctx.Redirect(setting.AppSubURL + "/explore")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
|
||||
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
|
||||
ctx.Data["Title"] = ctx.Tr("explore_title")
|
||||
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 = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
|
||||
ctx.SetFormString("sort", sortOrder)
|
||||
}
|
||||
|
||||
RenderUserSearch(ctx, user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Types: []user_model.UserType{user_model.UserTypeOrganization},
|
||||
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
|
||||
Visible: visibleTypes,
|
||||
|
||||
SupportedSortOrders: supportedSortOrders,
|
||||
}, tplExploreUsers)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package explore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/sitemap"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplExploreRepos explore repositories page template
|
||||
tplExploreRepos templates.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 templates.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 := ctx.PathParamInt("idx")
|
||||
isSitemap := ctx.PathParam("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(count, opts.PageSize, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, opts.TplName)
|
||||
}
|
||||
|
||||
// Repos render explore repositories page
|
||||
func Repos(ctx *context.Context) {
|
||||
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
|
||||
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
|
||||
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
|
||||
ctx.Data["Title"] = ctx.Tr("explore_title")
|
||||
ctx.Data["PageIsExplore"] = true
|
||||
ctx.Data["ShowRepoOwnerOnList"] = 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package explore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/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 := db.FindAndCount[repo_model.Topic](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.HTTPError(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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package explore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/sitemap"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplExploreUsers explore users page template
|
||||
tplExploreUsers templates.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 templates.TplName) {
|
||||
// Sitemap index for sitemap paths
|
||||
opts.Page = ctx.PathParamInt("idx")
|
||||
isSitemap := ctx.PathParam("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(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(ctx), 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(count, opts.PageSize, opts.Page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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")
|
||||
return
|
||||
}
|
||||
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
|
||||
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
|
||||
ctx.Data["Title"] = ctx.Tr("explore_title")
|
||||
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 = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
|
||||
ctx.SetFormString("sort", sortOrder)
|
||||
}
|
||||
|
||||
RenderUserSearch(ctx, user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Types: []user_model.UserType{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)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/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) {
|
||||
var commits []*git.Commit
|
||||
var err error
|
||||
if ctx.Repo.Commit != nil {
|
||||
commits, err = ctx.Repo.Commit.CommitsByRange(0, 10, "", "", "")
|
||||
if err != nil {
|
||||
ctx.ServerError("ShowBranchFeed", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
title := "Latest commits for branch " + ctx.Repo.BranchName
|
||||
link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.RefTypeNameSubURL()}
|
||||
|
||||
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: commit.MessageTitle(),
|
||||
Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()},
|
||||
Author: &feeds.Author{
|
||||
Name: commit.Author.Name,
|
||||
Email: commit.Author.Email,
|
||||
},
|
||||
Description: commit.MessageUTF8(), // TODO: description can be shorten content
|
||||
Content: commit.MessageUTF8(),
|
||||
Created: commit.Committer.When,
|
||||
})
|
||||
}
|
||||
|
||||
writeFeed(ctx, feed, formatType)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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 "gitea.dev/models/activities"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// renderCommentMarkdown renders the comment markdown to html
|
||||
func renderCommentMarkdown(ctx *context.Context, act *activities_model.Action, content string) template.HTML {
|
||||
_ = act.LoadRepo(ctx)
|
||||
if act.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, act.Repo).WithUseAbsoluteLink(true)
|
||||
rendered, err := markdown.RenderString(rctx, content)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
// feedActionsToFeedItems convert gitea's Action feed to feeds Item
|
||||
func feedActionsToFeedItems(ctx *context.Context, actions activities_model.ActionList) (items []*feeds.Item, err error) {
|
||||
renderUtils := templates.NewRenderUtils(ctx)
|
||||
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.RefName, 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.RefName, act.ShortRepoPath(ctx))
|
||||
case activities_model.ActionMirrorSyncDelete:
|
||||
link.Href = act.GetRepoAbsoluteLink(ctx)
|
||||
titleExtra = ctx.Locale.Tr("action.mirror_sync_delete", act.GetRepoAbsoluteLink(ctx), act.RefName, 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)
|
||||
_ = act.LoadRepo(ctx)
|
||||
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,
|
||||
renderUtils.RenderCommitMessage(commit.Message, act.Repo),
|
||||
)
|
||||
}
|
||||
|
||||
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 = renderCommentMarkdown(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 len(comment) != 0 {
|
||||
desc += "\n\n" + string(renderCommentMarkdown(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 = markup.Sanitize(desc)
|
||||
}
|
||||
|
||||
items = append(items, &feeds.Item{
|
||||
Title: template.HTMLEscapeString(title) + string(titleExtra),
|
||||
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) (showFeed bool, feedType string) {
|
||||
if strings.HasSuffix(name, ".rss") ||
|
||||
strings.Contains(req.Header.Get("Accept"), "application/rss+xml") {
|
||||
return true, "rss"
|
||||
}
|
||||
|
||||
if strings.HasSuffix(name, ".atom") ||
|
||||
strings.Contains(req.Header.Get("Accept"), "application/atom+xml") {
|
||||
return true, "atom"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// 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()}
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
|
||||
content, err = markdown.RenderString(rctx,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/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.RefFullName.ShortName(), // FIXME: legacy code used ShortName
|
||||
File: fileName,
|
||||
Page: 1,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("ShowBranchFeed", err)
|
||||
return
|
||||
}
|
||||
|
||||
title := "Latest commits for file " + ctx.Repo.TreePath
|
||||
|
||||
link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.RefTypeNameSubURL() + "/" + 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: commit.MessageTitle(),
|
||||
Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()},
|
||||
Author: &feeds.Author{
|
||||
Name: commit.Author.Name,
|
||||
Email: commit.Author.Email,
|
||||
},
|
||||
Description: commit.MessageUTF8(), // TODO: description can be shorten content
|
||||
Content: commit.MessageUTF8(),
|
||||
Created: commit.Committer.When,
|
||||
})
|
||||
}
|
||||
|
||||
writeFeed(ctx, feed, formatType)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/renderhelper"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/services/context"
|
||||
feed_service "gitea.dev/services/feed"
|
||||
|
||||
"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)
|
||||
isOrganisation := ctx.ContextUser.IsOrganization()
|
||||
if ctx.IsSigned && isOrganisation && !includePrivate {
|
||||
// When feed is requested by a member of the organization,
|
||||
// include the private repo's the member has access to.
|
||||
isOrgMember, err := organization.IsOrganizationMember(ctx, ctx.ContextUser.ID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("IsOrganizationMember", err)
|
||||
return
|
||||
}
|
||||
includePrivate = isOrgMember
|
||||
}
|
||||
|
||||
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
||||
RequestedUser: ctx.ContextUser,
|
||||
Actor: ctx.Doer,
|
||||
IncludePrivate: includePrivate,
|
||||
OnlyPerformedBy: !isOrganisation,
|
||||
IncludeDeleted: false,
|
||||
Date: ctx.FormString("date"),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetFeeds", err)
|
||||
return
|
||||
}
|
||||
|
||||
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, ctx.ContextUser.HTMLURL(ctx))
|
||||
ctxUserDescription, err := markdown.RenderString(rctx,
|
||||
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(ctx)},
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package feed_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/routers/web/feed"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
func TestCheckGetOrgFeedsAsOrgMember(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
t.Run("OrgMember", func(t *testing.T) {
|
||||
ctx, resp := contexttest.MockContext(t, "org3.atom")
|
||||
ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
feed.ShowUserFeedAtom(ctx)
|
||||
assert.Contains(t, resp.Body.String(), "<entry>") // Should contain 1 private entry
|
||||
})
|
||||
t.Run("NonOrgMember", func(t *testing.T) {
|
||||
ctx, resp := contexttest.MockContext(t, "org3.atom")
|
||||
ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
contexttest.LoadUser(t, ctx, 5)
|
||||
feed.ShowUserFeedAtom(ctx)
|
||||
assert.NotContains(t, resp.Body.String(), "<entry>") // Should not contain any entries
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/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)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// RenderBranchFeed render format for branch or file
|
||||
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
||||
if ctx.Repo.TreePath == "" {
|
||||
ShowBranchFeed(ctx, ctx.Repo.Repository, feedType)
|
||||
} else {
|
||||
ShowFileFeed(ctx, ctx.Repo.Repository, feedType)
|
||||
}
|
||||
}
|
||||
|
||||
func RenderBranchFeedRSS(ctx *context.Context) {
|
||||
RenderBranchFeed(ctx, "rss")
|
||||
}
|
||||
|
||||
func RenderBranchFeedAtom(ctx *context.Context) {
|
||||
RenderBranchFeed(ctx, "atom")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/services/context"
|
||||
feed_service "gitea.dev/services/feed"
|
||||
|
||||
"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 := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/web/repo"
|
||||
)
|
||||
|
||||
func addOwnerRepoGitHTTPRouters(m *web.Router, middlewares ...any) {
|
||||
m.Group("/{username}/{reponame}", func() {
|
||||
m.Methods("POST,OPTIONS", "/git-upload-pack", repo.ServiceUploadPack)
|
||||
m.Methods("POST,OPTIONS", "/git-receive-pack", repo.ServiceReceivePack)
|
||||
m.Methods("POST,OPTIONS", "/git-upload-archive", repo.ServiceUploadArchive)
|
||||
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)
|
||||
}, middlewares...)
|
||||
}
|
||||
@@ -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 "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func goGet(ctx *context.Context) {
|
||||
if ctx.Req.Method != http.MethodGet || 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(ctx, ownerName, trimmedRepoName)
|
||||
|
||||
var cloneURL string
|
||||
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
|
||||
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, ownerName, repoName)
|
||||
} else {
|
||||
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, 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))
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/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
|
||||
//
|
||||
// HINT: HEALTH-CHECK-ENDPOINT: there is no clear definition about what "health" means.
|
||||
// In most cases, end users don't need to check such endpoint, because even if database is down,
|
||||
// Gitea will reover after database is up again. Sysop should monitor database and cache status directly.
|
||||
//
|
||||
// And keep in mind: this health check should NEVER be used as a "restart" trigger, for example: Docker's "HEALTHCHECK".
|
||||
// * If Gitea is upgrading and migrating database, there will be a long time before this endpoint starts to return "pass" status.
|
||||
// In this case, if the checker restarts Gitea just because it doesn't get "pass" status in short time,
|
||||
// the instance will just be restarted again and again before the migration finishes and the situation just goes worse.
|
||||
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.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 _, 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)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/sitemap"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
"gitea.dev/routers/web/auth"
|
||||
"gitea.dev/routers/web/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplHome home page template
|
||||
tplHome templates.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{
|
||||
Types: []user_model.UserType{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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/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)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// Markup render markup document to HTML
|
||||
func Markup(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*api.MarkupOption)
|
||||
mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck // form.Wiki is deprecated
|
||||
common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, form.FilePath)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/httpcache"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func SiteManifest(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
if httpcache.HandleGenericETagPublicCache(req, w, "", &setting.AppStartTime) {
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := req.Context()
|
||||
absoluteAssetURL := strings.TrimSuffix(httplib.MakeAbsoluteURL(ctx, setting.StaticURLPrefix), "/")
|
||||
manifest := map[string]any{
|
||||
"name": setting.AppName,
|
||||
"short_name": setting.AppName,
|
||||
"start_url": httplib.GuessCurrentAppURL(ctx),
|
||||
"icons": []map[string]string{
|
||||
{"src": absoluteAssetURL + "/assets/img/logo.png", "type": "image/png", "sizes": "512x512"},
|
||||
{"src": absoluteAssetURL + "/assets/img/logo.svg", "type": "image/svg+xml", "sizes": "512x512"},
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(manifest)
|
||||
}
|
||||
|
||||
func SSHInfo(rw http.ResponseWriter, req *http.Request) {
|
||||
if !git.DefaultFeatures().SupportProcReceive {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("content-type", "text/json;charset=UTF-8")
|
||||
_, err := rw.Write([]byte(`{"type":"agit","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(), httpcache.CacheControlForPublicStatic())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func LocationRedirect(target string) func(w http.ResponseWriter, req *http.Request) {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, target, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func WebBannerDismiss(ctx *context.Context) {
|
||||
_, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx)
|
||||
middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600)
|
||||
ctx.JSONOK()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func Swagger(ctx *context.Context) {
|
||||
ctx.HTML(http.StatusOK, "swagger/openapi-viewer")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
"gitea.dev/services/context"
|
||||
user_service "gitea.dev/services/user"
|
||||
"gitea.dev/services/webtheme"
|
||||
)
|
||||
|
||||
func WebThemeList(ctx *context.Context) {
|
||||
curWebTheme := ctx.TemplateContext.CurrentWebTheme()
|
||||
renderUtils := templates.NewRenderUtils(ctx)
|
||||
allThemes := webtheme.GetAvailableThemes()
|
||||
|
||||
var results []map[string]any
|
||||
for _, theme := range allThemes {
|
||||
results = append(results, map[string]any{
|
||||
"name": renderUtils.RenderThemeItem(theme, 14),
|
||||
"value": theme.InternalName,
|
||||
"class": "item js-aria-clickable" + util.Iif(theme.InternalName == curWebTheme.InternalName, " selected", ""),
|
||||
})
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
func WebThemeApply(ctx *context.Context) {
|
||||
themeName := ctx.FormString("theme")
|
||||
if ctx.Doer != nil {
|
||||
opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)}
|
||||
_ = user_service.UpdateUser(ctx, ctx.Doer, opts)
|
||||
} else {
|
||||
middleware.SetSiteCookie(ctx.Resp, middleware.CookieTheme, themeName, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/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{{
|
||||
setting.AppURL + "api/v1/nodeinfo",
|
||||
"http://nodeinfo.diaspora.software/ns/schema/2.1",
|
||||
}},
|
||||
}
|
||||
ctx.JSON(http.StatusOK, nodeinfolinks)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/templates"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsBlockedUsers templates.TplName = "org/settings/blocked_users"
|
||||
)
|
||||
|
||||
func BlockedUsers(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("user.block.list")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsSettingsBlockedUsers"] = true
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
shared_user.BlockedUsers(ctx, ctx.ContextUser)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
|
||||
}
|
||||
|
||||
func BlockedUsersPost(ctx *context.Context) {
|
||||
shared_user.BlockedUsersPost(ctx, ctx.ContextUser, ctx.ContextUser.OrganisationLink()+"/settings/blocked_users")
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const tplOrgHome templates.TplName = "org/home"
|
||||
|
||||
// Home show organization home page
|
||||
func Home(ctx *context.Context) {
|
||||
uname := ctx.PathParam("username")
|
||||
|
||||
if strings.HasSuffix(uname, ".keys") || strings.HasSuffix(uname, ".gpg") {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetPathParam("org", uname)
|
||||
context.OrgAssignment(context.OrgAssignmentOptions{})(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
home(ctx, false)
|
||||
}
|
||||
|
||||
func Repositories(ctx *context.Context) {
|
||||
home(ctx, true)
|
||||
}
|
||||
|
||||
func home(ctx *context.Context, viewRepositories bool) {
|
||||
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
|
||||
|
||||
opts := &organization.FindOrgMembersOpts{
|
||||
Doer: ctx.Doer,
|
||||
OrgID: org.ID,
|
||||
IsDoerMember: ctx.Org.IsMember,
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 25},
|
||||
}
|
||||
|
||||
members, _, err := organization.FindOrgMembers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindOrgMembers", err)
|
||||
return
|
||||
}
|
||||
|
||||
const orgOverviewTeamsLimit = 5
|
||||
ctx.Data["OrgOverviewMembers"] = members
|
||||
ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)]
|
||||
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
|
||||
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
|
||||
|
||||
prepareResult, err := shared_user.RenderUserOrgHeader(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
// if no profile readme, it still means "view repositories"
|
||||
isViewOverview := !viewRepositories && prepareOrgProfileReadme(ctx, prepareResult)
|
||||
ctx.Data["PageIsViewRepositories"] = !isViewOverview
|
||||
ctx.Data["PageIsViewOverview"] = isViewOverview
|
||||
ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ctx.Data["Repos"] = repos
|
||||
ctx.Data["Total"] = count
|
||||
|
||||
pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgHome)
|
||||
}
|
||||
|
||||
func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOwnerHeaderResult) bool {
|
||||
viewAs := ctx.FormString("view_as", util.Iif(ctx.Org.IsMember, "member", "public"))
|
||||
viewAsMember := viewAs == "member"
|
||||
|
||||
var profileRepo *repo_model.Repository
|
||||
var readmeBlob *git.Blob
|
||||
if viewAsMember {
|
||||
if prepareResult.ProfilePrivateReadmeBlob != nil {
|
||||
profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob
|
||||
} else {
|
||||
profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob
|
||||
viewAsMember = false
|
||||
}
|
||||
} else {
|
||||
if prepareResult.ProfilePublicReadmeBlob != nil {
|
||||
profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob
|
||||
} else {
|
||||
profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob
|
||||
viewAsMember = true
|
||||
}
|
||||
}
|
||||
if readmeBlob == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
readmeBytes, err := readmeBlob.GetBlobContent(setting.UI.MaxDisplayFileSize)
|
||||
if err != nil {
|
||||
log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err)
|
||||
return false
|
||||
}
|
||||
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)),
|
||||
})
|
||||
ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes)
|
||||
if err != nil {
|
||||
log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err)
|
||||
return false
|
||||
}
|
||||
ctx.Data["IsViewingOrgAsMember"] = viewAsMember
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
org_service "gitea.dev/services/org"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplMembers template for organization members page
|
||||
tplMembers templates.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 := max(ctx.FormInt("page"), 1)
|
||||
|
||||
opts := &organization.FindOrgMembersOpts{
|
||||
Doer: ctx.Doer,
|
||||
OrgID: org.ID,
|
||||
}
|
||||
|
||||
if ctx.Doer != nil {
|
||||
isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "IsOrgMember")
|
||||
return
|
||||
}
|
||||
opts.IsDoerMember = isMember
|
||||
}
|
||||
ctx.Data["PublicOnly"] = opts.PublicOnly()
|
||||
|
||||
total, err := organization.CountOrgMembers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "CountOrgMembers")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
pager := context.NewPagination(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) {
|
||||
member, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
ctx.ServerError("GetUserByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
org := ctx.Org.Organization
|
||||
|
||||
switch ctx.PathParam("action") {
|
||||
case "private":
|
||||
if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, false)
|
||||
case "public":
|
||||
if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, true)
|
||||
case "remove":
|
||||
if !ctx.Org.IsOwner {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = org_service.RemoveOrgUser(ctx, org, member)
|
||||
case "leave":
|
||||
err = org_service.RemoveOrgUser(ctx, org, ctx.Doer)
|
||||
if err == nil {
|
||||
ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName()))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
ctx.JSONOK()
|
||||
return
|
||||
}
|
||||
|
||||
if organization.IsErrLastOrgOwner(err) {
|
||||
ctx.JSONError(ctx.Tr("form.last_org_owner"))
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Action(%s): %v", ctx.PathParam("action"), err)
|
||||
ctx.JSONError(err.Error()) // FIXME: legacy logic, errors are handled together, it's not right, need to distinguish between different errors
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/modules/util"
|
||||
shared_mention "gitea.dev/routers/web/shared/mention"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// GetMentionsInOwner returns JSON data for mention autocomplete on owner-level pages.
|
||||
func GetMentionsInOwner(ctx *context.Context) {
|
||||
// for individual users, we don't have a concept of "mentionable" users or teams, so just return an empty list
|
||||
if !ctx.ContextUser.IsOrganization() {
|
||||
ctx.JSON(http.StatusOK, []shared_mention.Mention{})
|
||||
return
|
||||
}
|
||||
|
||||
// for org, return members and teams
|
||||
c := shared_mention.NewCollector()
|
||||
org := organization.OrgFromUser(ctx.ContextUser)
|
||||
|
||||
// Get org members
|
||||
members, _, err := org.GetMembers(ctx, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetMembers", err)
|
||||
return
|
||||
}
|
||||
c.AddUsers(ctx, members)
|
||||
|
||||
// Get mentionable teams
|
||||
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.ContextUser); err != nil {
|
||||
ctx.ServerError("AddMentionableTeams", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplCreateOrg template path for create organization
|
||||
tplCreateOrg templates.TplName = "org/create"
|
||||
)
|
||||
|
||||
// Create render the page for create organization
|
||||
func Create(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("new_org")
|
||||
if !ctx.Doer.CanCreateOrganization() {
|
||||
ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed")))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["visibility"] = setting.Service.DefaultOrgVisibilityMode
|
||||
ctx.Data["repo_admin_change_team_access"] = true
|
||||
|
||||
ctx.HTML(http.StatusOK, tplCreateOrg)
|
||||
}
|
||||
|
||||
// CreatePost response for create organization
|
||||
func CreatePost(ctx *context.Context) {
|
||||
form := *web.GetForm(ctx).(*forms.CreateOrgForm)
|
||||
ctx.Data["Title"] = ctx.Tr("new_org")
|
||||
|
||||
if !ctx.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.RenderWithErrDeprecated(ctx.Tr("form.org_name_been_taken"), tplCreateOrg, &form)
|
||||
case db.IsErrNameReserved(err):
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("org.form.name_reserved", err.(db.ErrNameReserved).Name), tplCreateOrg, &form)
|
||||
case db.IsErrNamePatternNotAllowed(err):
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("org.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplCreateOrg, &form)
|
||||
case organization.IsErrUserNotAllowedCreateOrg(err):
|
||||
ctx.RenderWithErrDeprecated(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())
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/label"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
shared_label "gitea.dev/routers/web/shared/label"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/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 := shared_label.GetLabelEditForm(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
l := &issues_model.Label{
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
Name: form.Title,
|
||||
Exclusive: form.Exclusive,
|
||||
Description: form.Description,
|
||||
Color: form.Color,
|
||||
ExclusiveOrder: form.ExclusiveOrder,
|
||||
}
|
||||
if err := issues_model.NewLabel(ctx, l); err != nil {
|
||||
ctx.ServerError("NewLabel", err)
|
||||
return
|
||||
}
|
||||
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels")
|
||||
}
|
||||
|
||||
// UpdateLabel update a label's name and color
|
||||
func UpdateLabel(ctx *context.Context) {
|
||||
form := shared_label.GetLabelEditForm(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
l, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, form.ID)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.JSONErrorNotFound()
|
||||
return
|
||||
} else if err != nil {
|
||||
ctx.ServerError("GetLabelInOrgByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
l.Name = form.Title
|
||||
l.Exclusive = form.Exclusive
|
||||
l.ExclusiveOrder = form.ExclusiveOrder
|
||||
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.JSONRedirect(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")
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/web/shared/issue"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
project_service "gitea.dev/services/projects"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
const (
|
||||
tplProjects templates.TplName = "org/projects/list"
|
||||
tplProjectsNew templates.TplName = "org/projects/new"
|
||||
tplProjectsView templates.TplName = "org/projects/view"
|
||||
)
|
||||
|
||||
// Projects renders the home page of projects
|
||||
func Projects(ctx *context.Context) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Title"] = ctx.Tr("repo.projects")
|
||||
|
||||
sortType := ctx.FormTrim("sort")
|
||||
|
||||
isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed"
|
||||
keyword := ctx.FormTrim("q")
|
||||
page := max(ctx.FormInt("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
|
||||
}
|
||||
|
||||
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
|
||||
ctx.ServerError("LoadIssueNumbersForProjects", 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
|
||||
|
||||
if isShowClosed {
|
||||
ctx.Data["State"] = "closed"
|
||||
} else {
|
||||
ctx.Data["State"] = "open"
|
||||
}
|
||||
renderUtils := templates.NewRenderUtils(ctx)
|
||||
for _, project := range projects {
|
||||
project.RenderedContent = renderUtils.MarkdownToHtml(project.Description)
|
||||
}
|
||||
|
||||
pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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"
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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")
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
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.PathParam("action") {
|
||||
case "open":
|
||||
toClose = false
|
||||
case "close":
|
||||
toClose = true
|
||||
default:
|
||||
ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects")
|
||||
return
|
||||
}
|
||||
id := ctx.PathParamInt64("id")
|
||||
|
||||
project, err := project_model.GetProjectByIDAndOwner(ctx, id, ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, project.ID, toClose); err != nil {
|
||||
ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err)
|
||||
return
|
||||
}
|
||||
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, project.ID))
|
||||
}
|
||||
|
||||
// DeleteProject delete a project
|
||||
func DeleteProject(ctx *context.Context) {
|
||||
p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
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()
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
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"] = project_model.ProjectLinkForOrg(ctx.ContextUser, 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.PathParamInt64("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"] = project_model.ProjectLinkForOrg(ctx.ContextUser, projectID)
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplProjectsNew)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := project_model.GetProjectByIDAndOwner(ctx, projectID, ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
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.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := project.LoadOwner(ctx); err != nil {
|
||||
ctx.ServerError("LoadOwner", err)
|
||||
return
|
||||
}
|
||||
|
||||
columns, err := project.GetColumns(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetProjectColumns", err)
|
||||
return
|
||||
}
|
||||
|
||||
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
assigneeID := ctx.FormString("assignee")
|
||||
milestoneID := ctx.FormInt64("milestone")
|
||||
|
||||
// Prepare milestone IDs for filtering
|
||||
var milestoneIDs []int64
|
||||
if milestoneID > 0 {
|
||||
milestoneIDs = []int64{milestoneID}
|
||||
} else if milestoneID == db.NoConditionID {
|
||||
milestoneIDs = []int64{db.NoConditionID}
|
||||
}
|
||||
|
||||
opts := issues_model.IssuesOptions{
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
AssigneeID: assigneeID,
|
||||
MilestoneIDs: milestoneIDs,
|
||||
Owner: project.Owner,
|
||||
}
|
||||
if ctx.Doer != nil {
|
||||
opts.Doer = ctx.Doer
|
||||
} else {
|
||||
opts.AllPublic = true
|
||||
}
|
||||
|
||||
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("LoadIssuesOfColumns", err)
|
||||
return
|
||||
}
|
||||
for _, column := range columns {
|
||||
column.NumIssues = int64(len(issuesMap[column.ID]))
|
||||
}
|
||||
|
||||
if project.CardType != project_model.CardTypeTextOnly {
|
||||
issuesAttachmentMap := make(map[int64][]*repo_model.Attachment)
|
||||
for _, issuesList := range issuesMap {
|
||||
for _, issue := range issuesList {
|
||||
if issueAttachment, err := repo_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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add option to filter also by repository specific labels
|
||||
labels, err := issues_model.GetLabelsByOrgID(ctx, project.OwnerID, "", db.ListOptions{})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLabelsByOrgID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the exclusive scope for every label ID
|
||||
labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
|
||||
for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
|
||||
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(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
|
||||
}
|
||||
ctx.Data["Labels"] = labels
|
||||
ctx.Data["NumLabels"] = len(labels)
|
||||
|
||||
// Get milestones for filtering
|
||||
// For organization projects, we need to get milestones from all repos the user has access to
|
||||
var milestones issues_model.MilestoneList
|
||||
if project.RepoID > 0 {
|
||||
// Repo-specific project
|
||||
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
||||
RepoID: project.RepoID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepoMilestones", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Organization-wide project - get milestones from all organization repos
|
||||
// but only from repositories the current user can access.
|
||||
// Use RepoCond with a subquery to avoid materializing all repo IDs in memory
|
||||
// which can hit SQL parameter limits for orgs with many repos.
|
||||
accessCond := repo_model.AccessibleRepositoryCondition(ctx.Doer, unit.TypeIssues)
|
||||
repoCond := builder.And(
|
||||
builder.Eq{"owner_id": project.OwnerID},
|
||||
accessCond,
|
||||
)
|
||||
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
||||
RepoCond: repoCond,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOrgMilestones", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
openMilestones, closedMilestones := milestones.SplitByOpenClosed()
|
||||
ctx.Data["OpenMilestones"] = openMilestones
|
||||
ctx.Data["ClosedMilestones"] = closedMilestones
|
||||
ctx.Data["MilestoneID"] = milestoneID
|
||||
|
||||
// Get assignees.
|
||||
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap)
|
||||
if err != nil {
|
||||
ctx.ServerError("LoadIssuesAssigneesForProject", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
||||
ctx.Data["AssigneeID"] = assigneeID
|
||||
|
||||
project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(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
|
||||
ctx.Data["Title"] = fmt.Sprintf("%s - %s", project.Title, ctx.ContextUser.DisplayName())
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("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.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.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.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
|
||||
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.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
|
||||
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_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
|
||||
ctx.ServerError("MoveIssuesOnProjectColumn", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/web/org"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/services/forms"
|
||||
|
||||
"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.SetPathParam("id", "4")
|
||||
ctx.SetPathParam("columnID", "4")
|
||||
|
||||
project, column := org.CheckProjectColumnChangePermissions(ctx)
|
||||
assert.NotNil(t, project)
|
||||
assert.NotNil(t, column)
|
||||
assert.False(t, ctx.Written())
|
||||
}
|
||||
|
||||
func TestChangeProjectStatusRejectsForeignProjects(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
// project 4 is owned by user2 not user1
|
||||
ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/close")
|
||||
contexttest.LoadUser(t, ctx, 1)
|
||||
ctx.ContextUser = ctx.Doer
|
||||
ctx.SetPathParam("action", "close")
|
||||
ctx.SetPathParam("id", "4")
|
||||
|
||||
org.ChangeProjectStatus(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
|
||||
}
|
||||
|
||||
func TestAddColumnToProjectPostRejectsForeignProjects(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/columns/new")
|
||||
contexttest.LoadUser(t, ctx, 1)
|
||||
ctx.ContextUser = ctx.Doer
|
||||
ctx.SetPathParam("id", "4")
|
||||
web.SetForm(ctx, &forms.EditProjectColumnForm{Title: "foreign"})
|
||||
|
||||
org.AddColumnToProjectPost(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/models/webhook"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
user_setting "gitea.dev/routers/web/user/setting"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
org_service "gitea.dev/services/org"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplSettingsOptions template path for render settings
|
||||
tplSettingsOptions templates.TplName = "org/settings/options"
|
||||
// tplSettingsHooks template path for render hook settings
|
||||
tplSettingsHooks templates.TplName = "org/settings/hooks"
|
||||
// tplSettingsLabels template path for render labels settings
|
||||
tplSettingsLabels templates.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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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 err := org_service.UpdateOrgEmailAddress(ctx, org, form.Email); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.Data["Err_Email"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplSettingsOptions, &form)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("UpdateOrgEmailAddress", err)
|
||||
return
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
FullName: optional.FromPtr(form.FullName),
|
||||
Description: optional.FromPtr(form.Description),
|
||||
Website: optional.FromPtr(form.Website),
|
||||
Location: optional.FromPtr(form.Location),
|
||||
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
|
||||
}
|
||||
if ctx.Doer.IsAdmin {
|
||||
opts.MaxRepoCreation = optional.FromPtr(form.MaxRepoCreation)
|
||||
}
|
||||
|
||||
if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", 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")
|
||||
}
|
||||
|
||||
// SettingsDeleteOrgPost response for deleting an organization
|
||||
func SettingsDeleteOrgPost(ctx *context.Context) {
|
||||
if ctx.Org.Organization.Name != ctx.FormString("org_name") {
|
||||
ctx.JSONError(ctx.Tr("form.enterred_invalid_org_name"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false /* no purge */); err != nil {
|
||||
if repo_model.IsErrUserOwnRepos(err) {
|
||||
ctx.JSONError(ctx.Tr("form.org_still_own_repo"))
|
||||
} else if packages_model.IsErrUserOwnPackages(err) {
|
||||
ctx.JSONError(ctx.Tr("form.org_still_own_packages"))
|
||||
} else {
|
||||
log.Error("DeleteOrganization: %v", err)
|
||||
ctx.JSONError(util.Iif(ctx.Doer.IsAdmin, err.Error(), string(ctx.Tr("org.settings.delete_failed"))))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.delete_successful", ctx.Org.Organization.Name))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/")
|
||||
}
|
||||
|
||||
// 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["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
|
||||
}
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsLabels)
|
||||
}
|
||||
|
||||
// SettingsRenamePost response for renaming organization
|
||||
func SettingsRenamePost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.RenameOrgForm)
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
oldOrgName, newOrgName := ctx.Org.Organization.Name, form.NewOrgName
|
||||
|
||||
if form.OrgName != oldOrgName {
|
||||
ctx.JSONError(ctx.Tr("form.enterred_invalid_org_name"))
|
||||
return
|
||||
}
|
||||
if newOrgName == oldOrgName {
|
||||
ctx.JSONError(ctx.Tr("org.settings.rename_no_change"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := user_service.RenameUser(ctx, ctx.Org.Organization.AsUser(), newOrgName, ctx.Doer); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) {
|
||||
ctx.JSONError(ctx.Tr("org.form.name_been_taken", newOrgName))
|
||||
} else if db.IsErrNameReserved(err) {
|
||||
ctx.JSONError(ctx.Tr("org.form.name_reserved", newOrgName))
|
||||
} else if db.IsErrNamePatternNotAllowed(err) {
|
||||
ctx.JSONError(ctx.Tr("org.form.name_pattern_not_allowed", newOrgName))
|
||||
} else {
|
||||
log.Error("RenameOrganization: %v", err)
|
||||
ctx.JSONError(util.Iif(ctx.Doer.IsAdmin, err.Error(), string(ctx.Tr("org.settings.rename_failed"))))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.rename_success", oldOrgName, newOrgName))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(newOrgName) + "/settings")
|
||||
}
|
||||
|
||||
// SettingsChangeVisibilityPost response for change organization visibility
|
||||
func SettingsChangeVisibilityPost(ctx *context.Context) {
|
||||
visibility, ok := structs.VisibilityModes[ctx.FormString("visibility")]
|
||||
if !ok {
|
||||
ctx.Flash.Error(ctx.Tr("invalid_data", visibility))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings")
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Org.Organization.Visibility == visibility {
|
||||
ctx.Flash.Info(ctx.Tr("nothing_has_been_changed"))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings")
|
||||
return
|
||||
}
|
||||
|
||||
if err := org_service.ChangeOrganizationVisibility(ctx, ctx.Org.Organization, visibility); err != nil {
|
||||
log.Error("ChangeOrganizationVisibility: %v", err)
|
||||
ctx.JSONError(ctx.Tr("error.occurred"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.change_visibility_success", ctx.Org.Organization.Name))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
user_setting "gitea.dev/routers/web/user/setting"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsApplications templates.TplName = "org/settings/applications"
|
||||
tplSettingsOAuthApplicationEdit templates.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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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_title")
|
||||
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
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
shared "gitea.dev/routers/web/shared/packages"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsPackages templates.TplName = "org/settings/packages"
|
||||
tplSettingsPackagesRuleEdit templates.TplName = "org/settings/packages_cleanup_rules_edit"
|
||||
tplSettingsPackagesRulePreview templates.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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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))
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
org_model "gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
"gitea.dev/services/forms"
|
||||
org_service "gitea.dev/services/org"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplTeams template path for teams list page
|
||||
tplTeams templates.TplName = "org/team/teams"
|
||||
// tplTeamNew template path for create new team page
|
||||
tplTeamNew templates.TplName = "org/team/new"
|
||||
// tplTeamMembers template path for showing team members page
|
||||
tplTeamMembers templates.TplName = "org/team/members"
|
||||
// tplTeamRepositories template path for showing team repositories page
|
||||
tplTeamRepositories templates.TplName = "org/team/repositories"
|
||||
// tplTeamInvite template path for team invites page
|
||||
tplTeamInvite templates.TplName = "org/team/invite"
|
||||
)
|
||||
|
||||
// Teams render teams list page
|
||||
func Teams(ctx *context.Context) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
org := ctx.Org.Organization
|
||||
ctx.Data["Title"] = org.FullName
|
||||
ctx.Data["PageIsOrgTeams"] = true
|
||||
|
||||
keyword := ctx.FormTrim("q")
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
pagingNum := setting.UI.MembersPagingNum
|
||||
|
||||
searchTeams := func() (teams []*org_model.Team, count int64, err error) {
|
||||
if keyword == "" {
|
||||
// fast path, use existing teams in context if no need to filter from database
|
||||
count = int64(len(ctx.Org.Teams))
|
||||
start := (page - 1) * pagingNum
|
||||
if start > len(ctx.Org.Teams) {
|
||||
return nil, count, nil
|
||||
}
|
||||
end := min(start+pagingNum, len(ctx.Org.Teams))
|
||||
return ctx.Org.Teams[start:end], count, nil
|
||||
}
|
||||
|
||||
shouldSeeAllOrgTeams, err := context.UserShouldSeeAllOrgTeams(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
opts := &org_model.SearchTeamOptions{
|
||||
OrgID: org.ID,
|
||||
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
|
||||
Keyword: keyword,
|
||||
IncludeDesc: true,
|
||||
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
|
||||
}
|
||||
return org_model.SearchTeam(ctx, opts)
|
||||
}
|
||||
|
||||
teams, count, err := searchTeams()
|
||||
if err != nil {
|
||||
ctx.ServerError("SearchTeam", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range teams {
|
||||
if err := t.LoadMembers(ctx); err != nil {
|
||||
ctx.ServerError("GetMembers", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["OrgListTeams"] = teams
|
||||
ctx.Data["Keyword"] = keyword
|
||||
pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
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.PathParam("action") {
|
||||
case "join":
|
||||
if !ctx.Org.IsOwner {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = org_service.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer)
|
||||
case "leave":
|
||||
err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer)
|
||||
if err != nil {
|
||||
if org_model.IsErrLastOrgOwner(err) {
|
||||
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
|
||||
} else {
|
||||
log.Error("Action(%s): %v", ctx.PathParam("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.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
|
||||
if user == nil {
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/teams")
|
||||
return
|
||||
}
|
||||
|
||||
err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, user)
|
||||
if err != nil {
|
||||
if org_model.IsErrLastOrgOwner(err) {
|
||||
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
|
||||
} else {
|
||||
log.Error("Action(%s): %v", ctx.PathParam("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.HTTPError(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 = org_service.AddTeamMember(ctx, ctx.Org.Team, u)
|
||||
}
|
||||
|
||||
page = "team"
|
||||
case "remove_invite":
|
||||
if !ctx.Org.IsOwner {
|
||||
ctx.HTTPError(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.PathParam("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 if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.Flash.Error(ctx.Tr("org.teams.members.blocked_user"))
|
||||
} else {
|
||||
log.Error("Action(%s): %v", ctx.PathParam("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 && !ctx.Doer.IsAdmin {
|
||||
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.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
action := ctx.PathParam("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 = repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo)
|
||||
case "remove":
|
||||
err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid"))
|
||||
case "addall":
|
||||
err = repo_service.AddAllRepositoriesToTeam(ctx, ctx.Org.Team)
|
||||
case "removeall":
|
||||
err = repo_service.RemoveAllRepositoriesFromTeam(ctx, ctx.Org.Team)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Action(%s): '%s' %v", ctx.PathParam("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) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
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
|
||||
ctx.HTML(http.StatusOK, tplTeamNew)
|
||||
}
|
||||
|
||||
// FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future,
|
||||
// The existing teams won't inherit the correct admin permission for the new unit.
|
||||
// The full history is like this:
|
||||
// 1. There was only "team", no "team unit", so "team.authorize" was used to determine the team permission.
|
||||
// 2. Later, "team unit" was introduced, then the usage of "team.authorize" became inconsistent, and causes various bugs.
|
||||
// - Sometimes, "team.authorize" is used to determine the team permission, e.g. admin, owner
|
||||
// - Sometimes, "team unit" is used not really used and "team unit" is used.
|
||||
// - Some functions like `GetTeamsWithAccessToAnyRepoUnit` use both.
|
||||
//
|
||||
// 3. After introducing "team unit" and more unclear changes, it becomes difficult to maintain team permissions.
|
||||
// - Org owner need to click the permission for each unit, but can't just set a common "write" permission for all units.
|
||||
//
|
||||
// Ideally, "team.authorize=write" should mean the team has write access to all units including newly (future) added ones.
|
||||
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 access mode 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"
|
||||
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
|
||||
unitPerms := getUnitPerms(ctx.Req.Form, teamPermission)
|
||||
|
||||
t := &org_model.Team{
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
Name: form.TeamName,
|
||||
Description: form.Description,
|
||||
AccessMode: teamPermission,
|
||||
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.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
|
||||
return
|
||||
}
|
||||
|
||||
if err := org_service.NewTeam(ctx, t); err != nil {
|
||||
ctx.Data["Err_TeamName"] = true
|
||||
switch {
|
||||
case org_model.IsErrTeamAlreadyExist(err):
|
||||
ctx.RenderWithErrDeprecated(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) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Org.Team.Name
|
||||
ctx.Data["PageIsOrgTeams"] = true
|
||||
ctx.Data["PageIsOrgTeamMembers"] = true
|
||||
|
||||
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) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Org.Team.Name
|
||||
ctx.Data["PageIsOrgTeams"] = true
|
||||
ctx.Data["PageIsOrgTeamRepos"] = true
|
||||
|
||||
repos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
|
||||
TeamID: ctx.Org.Team.ID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetTeamRepositories", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Units"] = unit_model.Units
|
||||
ctx.Data["TeamRepos"] = repos
|
||||
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) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
|
||||
unitPerms := getUnitPerms(ctx.Req.Form, teamPermission)
|
||||
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 != teamPermission {
|
||||
isAuthChanged = true
|
||||
t.AccessMode = teamPermission
|
||||
}
|
||||
|
||||
if t.IncludesAllRepositories != includesAllRepositories {
|
||||
isIncludeAllChanged = true
|
||||
t.IncludesAllRepositories = includesAllRepositories
|
||||
}
|
||||
t.CanCreateOrgRepo = form.CanCreateOrgRepo
|
||||
} else {
|
||||
t.CanCreateOrgRepo = true
|
||||
}
|
||||
|
||||
t.Description = form.Description
|
||||
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
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplTeamNew)
|
||||
return
|
||||
}
|
||||
|
||||
if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 {
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
|
||||
return
|
||||
}
|
||||
|
||||
if err := org_service.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil {
|
||||
ctx.Data["Err_TeamName"] = true
|
||||
switch {
|
||||
case org_model.IsErrTeamAlreadyExist(err):
|
||||
ctx.RenderWithErrDeprecated(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 := org_service.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)
|
||||
// TODO: to quickly debug the UI, can uncomment this (don't worry, it won't pass CI lint)
|
||||
// invite, org, team, inviter, err = &org_model.TeamInvite{}, &org_model.Organization{}, &org_model.Team{}, ctx.Doer, nil
|
||||
if err != nil {
|
||||
if org_model.IsErrTeamInviteNotFound(err) {
|
||||
ctx.NotFound(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(err)
|
||||
} else {
|
||||
ctx.ServerError("getTeamInviteFromContext", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := org_service.AddTeamMember(ctx, team, ctx.Doer); 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.PathParam("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
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/modules/templates"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const tplByRepos templates.TplName = "org/worktime"
|
||||
|
||||
// parseOrgTimes contains functionality that is required in all these functions,
|
||||
// like parsing the date from the request, setting default dates, etc.
|
||||
func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) {
|
||||
rangeFrom := ctx.FormString("from")
|
||||
rangeTo := ctx.FormString("to")
|
||||
if rangeFrom == "" {
|
||||
rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month
|
||||
}
|
||||
if rangeTo == "" {
|
||||
rangeTo = time.Now().Format("2006-01-02") // defaults to today
|
||||
}
|
||||
|
||||
ctx.Data["RangeFrom"] = rangeFrom
|
||||
ctx.Data["RangeTo"] = rangeTo
|
||||
|
||||
timeFrom, err := time.Parse("2006-01-02", rangeFrom)
|
||||
if err != nil {
|
||||
ctx.ServerError("time.Parse", err)
|
||||
}
|
||||
timeTo, err := time.Parse("2006-01-02", rangeTo)
|
||||
if err != nil {
|
||||
ctx.ServerError("time.Parse", err)
|
||||
}
|
||||
unixFrom = timeFrom.Unix()
|
||||
unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too
|
||||
return unixFrom, unixTo
|
||||
}
|
||||
|
||||
func Worktime(ctx *context.Context) {
|
||||
ctx.Data["PageIsOrgTimes"] = true
|
||||
|
||||
unixFrom, unixTo := parseOrgTimes(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
worktimeBy := ctx.FormString("by")
|
||||
ctx.Data["WorktimeBy"] = worktimeBy
|
||||
|
||||
var worktimeSumResult any
|
||||
var err error
|
||||
switch worktimeBy {
|
||||
case "milestones":
|
||||
worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx, ctx.Org.Organization, unixFrom, unixTo)
|
||||
ctx.Data["WorktimeByMilestones"] = true
|
||||
case "members":
|
||||
worktimeSumResult, err = organization.GetWorktimeByMembers(ctx, ctx.Org.Organization, unixFrom, unixTo)
|
||||
ctx.Data["WorktimeByMembers"] = true
|
||||
default: /* by repos */
|
||||
worktimeSumResult, err = organization.GetWorktimeByRepos(ctx, ctx.Org.Organization, unixFrom, unixTo)
|
||||
ctx.Data["WorktimeByRepos"] = true
|
||||
}
|
||||
if err != nil {
|
||||
ctx.ServerError("GetWorktime", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["WorktimeSumResult"] = worktimeSumResult
|
||||
ctx.HTML(http.StatusOK, tplByRepos)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
type passkeyEndpointsType struct {
|
||||
Enroll string `json:"enroll"`
|
||||
Manage string `json:"manage"`
|
||||
}
|
||||
|
||||
func passkeyEndpoints(ctx *context.Context) {
|
||||
url := setting.AppURL + "user/settings/security"
|
||||
ctx.JSON(http.StatusOK, passkeyEndpointsType{
|
||||
Enroll: url,
|
||||
Manage: url,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdCtx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
act_model "gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
tplListActions templates.TplName = "repo/actions/list"
|
||||
tplDispatchInputsActions templates.TplName = "repo/actions/workflow_dispatch_inputs"
|
||||
tplViewActions templates.TplName = "repo/actions/view"
|
||||
)
|
||||
|
||||
type WorkflowInfo struct {
|
||||
Entry git.TreeEntry
|
||||
ErrMsg string
|
||||
Workflow *act_model.Workflow
|
||||
}
|
||||
|
||||
// DisplayName returns the workflow name from the YAML file if present, otherwise the filename.
|
||||
func (w WorkflowInfo) DisplayName() string {
|
||||
if w.Workflow != nil && w.Workflow.Name != "" {
|
||||
return w.Workflow.Name
|
||||
}
|
||||
return w.Entry.Name()
|
||||
}
|
||||
|
||||
// MustEnableActions check if actions are enabled in settings
|
||||
func MustEnableActions(ctx *context.Context) {
|
||||
if !setting.Actions.Enabled {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if unit.TypeActions.UnitGlobalDisabled() {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Repo.Repository != nil {
|
||||
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func List(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("actions.actions")
|
||||
ctx.Data["PageIsActions"] = true
|
||||
|
||||
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch)
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
} else if err != nil {
|
||||
ctx.ServerError("GetBranchCommit", err)
|
||||
return
|
||||
}
|
||||
|
||||
workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
otherWorkflows := prepareOtherWorkflows(ctx, workflows, curWorkflowID)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
prepareWorkflowList(ctx, workflows, otherWorkflows)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplListActions)
|
||||
}
|
||||
|
||||
// prepareOtherWorkflows surfaces historical runs whose workflow file no longer
|
||||
// exists on the default branch (renamed, removed, or only on other branches).
|
||||
func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, curWorkflowID string) []string {
|
||||
listed := make(container.Set[string], len(workflows))
|
||||
for _, w := range workflows {
|
||||
listed.Add(w.Entry.Name())
|
||||
}
|
||||
|
||||
var other []string
|
||||
if ctx.Repo.Repository.NumActionRuns > 0 {
|
||||
ids, err := actions_model.GetRunWorkflowIDs(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunWorkflowIDs", err)
|
||||
return nil
|
||||
}
|
||||
other = container.FilterSlice(ids, func(id string) (string, bool) {
|
||||
return id, id != "" && !listed.Contains(id)
|
||||
})
|
||||
}
|
||||
|
||||
ctx.Data["OtherWorkflows"] = other
|
||||
ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID)
|
||||
return other
|
||||
}
|
||||
|
||||
func WorkflowDispatchInputs(ctx *context.Context) {
|
||||
ref := ctx.FormString("ref")
|
||||
if ref == "" {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
// get target commit of run from specified ref
|
||||
refName := git.RefName(ref)
|
||||
var commit *git.Commit
|
||||
var err error
|
||||
if refName.IsTag() {
|
||||
commit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName())
|
||||
} else if refName.IsBranch() {
|
||||
commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName())
|
||||
} else {
|
||||
ctx.ServerError("UnsupportedRefType", nil)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ctx.ServerError("GetTagCommit/GetBranchCommit", err)
|
||||
return
|
||||
}
|
||||
workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, tplDispatchInputsActions)
|
||||
}
|
||||
|
||||
func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflows []WorkflowInfo, curWorkflowID string) {
|
||||
curWorkflowID = ctx.FormString("workflow")
|
||||
|
||||
_, entries, err := actions.ListWorkflows(commit)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListWorkflows", err)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
workflows = make([]WorkflowInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
workflow := WorkflowInfo{Entry: *entry}
|
||||
content, err := actions.GetContentFromEntry(entry)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetContentFromEntry", err)
|
||||
return nil, ""
|
||||
}
|
||||
wf, err := act_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
|
||||
}
|
||||
if err := actions.ValidateWorkflowContent(content); err != nil {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
|
||||
workflows = append(workflows, workflow)
|
||||
continue
|
||||
}
|
||||
workflow.Workflow = wf
|
||||
// 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 you 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
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
ctx.Data["workflows"] = workflows
|
||||
ctx.Data["RepoLink"] = ctx.Repo.Repository.Link()
|
||||
ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.Permission.IsAdmin()
|
||||
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
|
||||
ctx.Data["ActionsConfig"] = actionsConfig
|
||||
ctx.Data["CurWorkflow"] = curWorkflowID
|
||||
ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflowID)
|
||||
|
||||
return workflows, curWorkflowID
|
||||
}
|
||||
|
||||
func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string) {
|
||||
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
|
||||
if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) || actionsConfig.IsWorkflowDisabled(curWorkflowID) {
|
||||
return
|
||||
}
|
||||
|
||||
var curWorkflow *act_model.Workflow
|
||||
for _, workflowInfo := range workflowInfos {
|
||||
if workflowInfo.Entry.Name() == curWorkflowID {
|
||||
if workflowInfo.Workflow == nil {
|
||||
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
|
||||
return
|
||||
}
|
||||
curWorkflow = workflowInfo.Workflow
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if curWorkflow == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["CurWorkflowExists"] = true
|
||||
curWfDispatchCfg := workflowDispatchConfig(curWorkflow)
|
||||
if curWfDispatchCfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["WorkflowDispatchConfig"] = curWfDispatchCfg
|
||||
|
||||
branchOpts := git_model.FindBranchOptions{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
IsDeletedBranch: optional.Some(false),
|
||||
ListOptions: db.ListOptions{
|
||||
ListAll: true,
|
||||
},
|
||||
}
|
||||
branches, err := git_model.FindBranchNames(ctx, branchOpts)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindBranchNames", err)
|
||||
return
|
||||
}
|
||||
// always put default branch on the top
|
||||
branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch)
|
||||
branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...)
|
||||
ctx.Data["Branches"] = branches
|
||||
|
||||
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetTagNamesByRepoID", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Tags"] = tags
|
||||
}
|
||||
|
||||
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string) {
|
||||
actorID := ctx.FormInt64("actor")
|
||||
status := ctx.FormInt("status")
|
||||
workflowID := ctx.FormString("workflow")
|
||||
branch := ctx.FormString("branch")
|
||||
page := ctx.FormInt("page")
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// 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
|
||||
ctx.Data["CurBranch"] = branch
|
||||
if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" {
|
||||
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: workflowID,
|
||||
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)}
|
||||
}
|
||||
if branch != "" {
|
||||
opts.Ref = string(git.RefNameFromBranch(branch))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil {
|
||||
log.Error("LoadIsRefDeleted", err)
|
||||
}
|
||||
|
||||
// Check for each run if there is at least one online runner that can run its jobs
|
||||
runErrors := make(map[int64]string)
|
||||
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
|
||||
}
|
||||
for _, run := range runs {
|
||||
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) {
|
||||
continue
|
||||
}
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunJobsByRunID", err)
|
||||
return
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsWaiting() {
|
||||
continue
|
||||
}
|
||||
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
|
||||
break
|
||||
}
|
||||
hasOnlineRunner := false
|
||||
for _, runner := range runners {
|
||||
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
|
||||
hasOnlineRunner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOnlineRunner {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["RunErrors"] = runErrors
|
||||
|
||||
ctx.Data["Runs"] = runs
|
||||
|
||||
workflowNames := make(map[string]string, len(workflows))
|
||||
for _, wf := range workflows {
|
||||
workflowNames[wf.Entry.Name()] = wf.DisplayName()
|
||||
}
|
||||
ctx.Data["WorkflowNames"] = workflowNames
|
||||
|
||||
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetActors", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors)
|
||||
|
||||
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
|
||||
|
||||
runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunBranches", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["RunBranches"] = runBranches
|
||||
|
||||
pager := context.NewPagination(total, opts.PageSize, opts.Page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0
|
||||
|
||||
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions)
|
||||
}
|
||||
|
||||
// loadIsRefDeleted loads the IsRefDeleted field for each run in the list.
|
||||
// TODO: move this function to models/actions/run_list.go but now it will result in a circular import.
|
||||
func loadIsRefDeleted(ctx stdCtx.Context, repoID int64, runs actions_model.RunList) error {
|
||||
branches := make(container.Set[string], len(runs))
|
||||
for _, run := range runs {
|
||||
refName := git.RefName(run.Ref)
|
||||
if refName.IsBranch() {
|
||||
branches.Add(refName.ShortName())
|
||||
}
|
||||
}
|
||||
if len(branches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
branchInfos, err := git_model.GetBranches(ctx, repoID, branches.Values(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branchSet := git_model.BranchesToNamesSet(branchInfos)
|
||||
for _, run := range runs {
|
||||
refName := git.RefName(run.Ref)
|
||||
if refName.IsBranch() && !branchSet.Contains(refName.ShortName()) {
|
||||
run.IsRefDeleted = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorkflowDispatchInput struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Required bool `yaml:"required"`
|
||||
Default string `yaml:"default"`
|
||||
Type string `yaml:"type"`
|
||||
Options []string `yaml:"options"`
|
||||
}
|
||||
|
||||
type WorkflowDispatch struct {
|
||||
Inputs []WorkflowDispatchInput
|
||||
}
|
||||
|
||||
func workflowDispatchConfig(w *act_model.Workflow) *WorkflowDispatch {
|
||||
switch w.RawOn.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var val string
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return nil
|
||||
}
|
||||
if val == "workflow_dispatch" {
|
||||
return &WorkflowDispatch{}
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
var val []string
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(val, "workflow_dispatch") {
|
||||
return &WorkflowDispatch{}
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
var val map[string]yaml.Node
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return nil
|
||||
}
|
||||
|
||||
workflowDispatchNode, found := val["workflow_dispatch"]
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
var workflowDispatch WorkflowDispatch
|
||||
var workflowDispatchVal map[string]yaml.Node
|
||||
if !decodeNode(workflowDispatchNode, &workflowDispatchVal) {
|
||||
return &workflowDispatch
|
||||
}
|
||||
|
||||
inputsNode, found := workflowDispatchVal["inputs"]
|
||||
if !found || inputsNode.Kind != yaml.MappingNode {
|
||||
return &workflowDispatch
|
||||
}
|
||||
|
||||
i := 0
|
||||
for {
|
||||
if i+1 >= len(inputsNode.Content) {
|
||||
break
|
||||
}
|
||||
var input WorkflowDispatchInput
|
||||
if decodeNode(*inputsNode.Content[i+1], &input) {
|
||||
input.Name = inputsNode.Content[i].Value
|
||||
workflowDispatch.Inputs = append(workflowDispatch.Inputs, input)
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
return &workflowDispatch
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeNode(node yaml.Node, out any) bool {
|
||||
if err := node.Decode(out); err != nil {
|
||||
log.Warn("Failed to decode node %v into %T: %v", node, out, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func actionsListRedirectURL(repoLink, workflow, actor, status, branch string) string {
|
||||
return fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s&branch=%s",
|
||||
repoLink,
|
||||
url.QueryEscape(workflow),
|
||||
url.QueryEscape(actor),
|
||||
url.QueryEscape(status),
|
||||
url.QueryEscape(branch),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
unittest "gitea.dev/models/unittest"
|
||||
|
||||
act_model "gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
`
|
||||
workflow, err := act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch := workflowDispatchConfig(workflow)
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: push
|
||||
`
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: workflow_dispatch
|
||||
`
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Nil(t, workflowDispatch.Inputs)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: [push, pull_request]
|
||||
`
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
`
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: [push, workflow_dispatch]
|
||||
`
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Nil(t, workflowDispatch.Inputs)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
- push
|
||||
- workflow_dispatch
|
||||
`
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Nil(t, workflowDispatch.Inputs)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
`
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Nil(t, workflowDispatch.Inputs)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
type: choice
|
||||
options:
|
||||
- info
|
||||
- warning
|
||||
- debug
|
||||
boolean_default_true:
|
||||
description: 'Test scenario tags'
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
boolean_default_false:
|
||||
description: 'Test scenario tags'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
`
|
||||
|
||||
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed")
|
||||
workflowDispatch = workflowDispatchConfig(workflow)
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Equal(t, WorkflowDispatchInput{
|
||||
Name: "logLevel",
|
||||
Default: "warning",
|
||||
Description: "Log level",
|
||||
Options: []string{
|
||||
"info",
|
||||
"warning",
|
||||
"debug",
|
||||
},
|
||||
Required: true,
|
||||
Type: "choice",
|
||||
}, workflowDispatch.Inputs[0])
|
||||
assert.Equal(t, WorkflowDispatchInput{
|
||||
Name: "boolean_default_true",
|
||||
Default: "true",
|
||||
Description: "Test scenario tags",
|
||||
Required: true,
|
||||
Type: "boolean",
|
||||
}, workflowDispatch.Inputs[1])
|
||||
assert.Equal(t, WorkflowDispatchInput{
|
||||
Name: "boolean_default_false",
|
||||
Default: "false",
|
||||
Description: "Test scenario tags",
|
||||
Required: true,
|
||||
Type: "boolean",
|
||||
}, workflowDispatch.Inputs[2])
|
||||
}
|
||||
|
||||
func Test_loadIsRefDeleted(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
runs, total, err := db.FindAndCount[actions_model.ActionRun](t.Context(),
|
||||
actions_model.FindRunOptions{RepoID: 4, Ref: "refs/heads/test"})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, runs, 1)
|
||||
assert.EqualValues(t, 1, total)
|
||||
for _, run := range runs {
|
||||
assert.False(t, run.IsRefDeleted)
|
||||
}
|
||||
|
||||
assert.NoError(t, loadIsRefDeleted(t.Context(), 4, runs))
|
||||
for _, run := range runs {
|
||||
assert.True(t, run.IsRefDeleted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/badge"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func GetWorkflowBadge(ctx *context.Context) {
|
||||
workflowFile := ctx.PathParam("workflow_name")
|
||||
branch := ctx.FormString("branch", ctx.Repo.Repository.DefaultBranch)
|
||||
event := ctx.FormString("event")
|
||||
style := ctx.FormString("style")
|
||||
|
||||
branchRef := git.RefNameFromBranch(branch)
|
||||
b, err := getWorkflowBadge(ctx, workflowFile, branchRef.String(), event)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetWorkflowBadge", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Badge"] = b
|
||||
ctx.RespHeader().Set("Content-Type", "image/svg+xml")
|
||||
switch style {
|
||||
case badge.StyleFlatSquare:
|
||||
ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat-square")
|
||||
default: // defaults to badge.StyleFlat
|
||||
ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat")
|
||||
}
|
||||
}
|
||||
|
||||
func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event string) (badge.Badge, error) {
|
||||
extension := filepath.Ext(workflowFile)
|
||||
workflowName := strings.TrimSuffix(workflowFile, extension)
|
||||
|
||||
run, err := actions_model.GetWorkflowLatestRun(ctx, ctx.Repo.Repository.ID, workflowFile, branchName, event)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
return badge.GenerateBadge(workflowName, "no status", badge.DefaultColor), nil
|
||||
}
|
||||
return badge.Badge{}, err
|
||||
}
|
||||
|
||||
color, ok := badge.GlobalVars().StatusColorMap[run.Status]
|
||||
if !ok {
|
||||
return badge.GenerateBadge(workflowName, "unknown status", badge.DefaultColor), nil
|
||||
}
|
||||
return badge.GenerateBadge(workflowName, run.Status.String(), color), nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertToViewModel(t *testing.T) {
|
||||
task := &actions_model.ActionTask{
|
||||
Status: actions_model.StatusSuccess,
|
||||
Steps: []*actions_model.ActionTaskStep{
|
||||
{Name: "Run step-name", Index: 0, Status: actions_model.StatusSuccess, LogLength: 1, Started: timeutil.TimeStamp(1), Stopped: timeutil.TimeStamp(5)},
|
||||
},
|
||||
Stopped: timeutil.TimeStamp(20),
|
||||
}
|
||||
|
||||
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedViewJobs := []*ViewJobStep{
|
||||
{
|
||||
Summary: "Set up job",
|
||||
Duration: "0s",
|
||||
Status: "success",
|
||||
},
|
||||
{
|
||||
Summary: "Run step-name",
|
||||
Duration: "4s",
|
||||
Status: "success",
|
||||
},
|
||||
{
|
||||
Summary: "Complete job",
|
||||
Duration: "15s",
|
||||
Status: "success",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
||||
}
|
||||
|
||||
func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T) {
|
||||
task := &actions_model.ActionTask{
|
||||
Status: actions_model.StatusCancelling,
|
||||
Steps: []*actions_model.ActionTaskStep{
|
||||
{Name: "Run step-name", Index: 0, Status: actions_model.StatusRunning, LogLength: 1},
|
||||
},
|
||||
}
|
||||
|
||||
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedViewJobs := []*ViewJobStep{
|
||||
{
|
||||
Summary: "Set up job",
|
||||
Duration: "0s",
|
||||
Status: "success",
|
||||
},
|
||||
{
|
||||
Summary: "Run step-name",
|
||||
Duration: "0s",
|
||||
Status: "cancelling",
|
||||
},
|
||||
{
|
||||
Summary: "Complete job",
|
||||
Duration: "0s",
|
||||
Status: "waiting",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplActivity templates.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
|
||||
|
||||
timeUntil := time.Now()
|
||||
period, timeFrom := "weekly", timeUntil.Add(-time.Hour*168)
|
||||
switch ctx.PathParam("period") {
|
||||
case "daily":
|
||||
period, timeFrom = "daily", timeUntil.Add(-time.Hour*24)
|
||||
case "halfweekly":
|
||||
period, timeFrom = "halfweekly", timeUntil.Add(-time.Hour*72)
|
||||
case "weekly":
|
||||
period, timeFrom = "weekly", timeUntil.Add(-time.Hour*168)
|
||||
case "monthly":
|
||||
period, timeFrom = "monthly", timeUntil.AddDate(0, -1, 0)
|
||||
case "quarterly":
|
||||
period, timeFrom = "quarterly", timeUntil.AddDate(0, -3, 0)
|
||||
case "semiyearly":
|
||||
period, timeFrom = "semiyearly", timeUntil.AddDate(0, -6, 0)
|
||||
case "yearly":
|
||||
period, timeFrom = "yearly", timeUntil.AddDate(-1, 0, 0)
|
||||
}
|
||||
ctx.Data["DateFrom"] = timeFrom
|
||||
ctx.Data["DateUntil"] = timeUntil
|
||||
ctx.Data["Period"] = period
|
||||
ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + period)
|
||||
|
||||
canReadCode := ctx.Repo.Permission.CanRead(unit.TypeCode)
|
||||
if canReadCode {
|
||||
// GetActivityStats needs to read the default branch to get some information
|
||||
branchExist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, ctx.Repo.Repository.DefaultBranch)
|
||||
if !branchExist {
|
||||
ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch)
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
// TODO: refactor these arguments to a struct
|
||||
ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom,
|
||||
ctx.Repo.Permission.CanRead(unit.TypeReleases),
|
||||
ctx.Repo.Permission.CanRead(unit.TypeIssues),
|
||||
ctx.Repo.Permission.CanRead(unit.TypePullRequests),
|
||||
canReadCode,
|
||||
)
|
||||
if 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.PathParam("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)
|
||||
}
|
||||
|
||||
authors, err := activities_model.GetActivityStatsTopAuthors(ctx, ctx.Repo.Repository, timeFrom, 10)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetActivityStatsTopAuthors", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, authors)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/httpcache"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/services/attachment"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/context/upload"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
func attachmentReadScope(unitType unit.Type) (auth_model.AccessTokenScope, bool) {
|
||||
switch unitType {
|
||||
case unit.TypeIssues, unit.TypePullRequests:
|
||||
return auth_model.AccessTokenScopeReadIssue, true
|
||||
case unit.TypeReleases:
|
||||
return auth_model.AccessTokenScopeReadRepository, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// UploadIssueAttachment response for Issue/PR attachments
|
||||
func UploadIssueAttachment(ctx *context.Context) {
|
||||
uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForIssue)
|
||||
}
|
||||
|
||||
// UploadReleaseAttachment response for uploading release attachments
|
||||
func UploadReleaseAttachment(ctx *context.Context) {
|
||||
uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForRelease)
|
||||
}
|
||||
|
||||
// UploadAttachment response for uploading attachments
|
||||
func uploadAttachment(ctx *context.Context, repoID int64, uploadFunc attachment.UploadAttachmentFunc) {
|
||||
if !setting.Attachment.Enabled {
|
||||
ctx.HTTPError(http.StatusNotFound, "attachment is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := ctx.Req.FormFile("file")
|
||||
if err != nil {
|
||||
ctx.ServerError("FormFile", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
uploaderFile := attachment.NewLimitedUploaderKnownSize(file, header.Size)
|
||||
attach, err := uploadFunc(ctx, uploaderFile, &repo_model.Attachment{
|
||||
Name: header.Filename,
|
||||
UploaderID: ctx.Doer.ID,
|
||||
RepoID: repoID,
|
||||
})
|
||||
if err != nil {
|
||||
if upload.IsErrFileTypeForbidden(err) {
|
||||
ctx.HTTPError(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.ServerError("uploadAttachment(uploadFunc)", 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.HTTPError(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.IsSigned {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if attach.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.HTTPError(http.StatusBadRequest, "attachment does not belong to this repository")
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer.ID != attach.UploaderID {
|
||||
if attach.IssueID > 0 {
|
||||
issue, err := issues_model.GetIssueByID(ctx, attach.IssueID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByID", err)
|
||||
return
|
||||
}
|
||||
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else if attach.ReleaseID > 0 {
|
||||
if !ctx.Repo.Permission.CanWrite(unit.TypeReleases) {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.IsOwner() {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = repo_model.DeleteAttachment(ctx, attach, true)
|
||||
if err != nil {
|
||||
ctx.ServerError("DeleteAttachment", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"uuid": attach.UUID,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeAttachment 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.HTTPError(http.StatusNotFound)
|
||||
} else {
|
||||
ctx.ServerError("GetAttachmentByUUID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// prevent visiting attachment from other repository directly
|
||||
// The check will be ignored before this code merged.
|
||||
if attach.CreatedUnix > repo_model.LegacyAttachmentMissingRepoIDCutoff && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID != attach.RepoID {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
unitType, repoID, err := repo_service.GetAttachmentLinkedTypeAndRepoID(ctx, attach)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAttachmentLinkedTypeAndRepoID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if unitType == unit.TypeInvalid { // unlinked attachment can only be accessed by the uploader
|
||||
if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
} else { // If we have the linked type, we need to check access
|
||||
var (
|
||||
perm access_model.Permission
|
||||
repo = ctx.Repo.Repository
|
||||
)
|
||||
if repo == nil {
|
||||
repo, err = repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepositoryByID", err)
|
||||
return
|
||||
}
|
||||
perm, err = access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDoerRepoPermission", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
perm = ctx.Repo.Permission
|
||||
}
|
||||
|
||||
if !perm.CanRead(unitType) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if requiredScope, ok := attachmentReadScope(unitType); ok {
|
||||
context.CheckTokenScopes(ctx, repo, requiredScope)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := attach.IncreaseDownloadCount(ctx); err != nil {
|
||||
ctx.ServerError("IncreaseDownloadCount", err)
|
||||
return
|
||||
}
|
||||
|
||||
if setting.Attachment.Storage.ServeDirect() {
|
||||
// If we have a signed url (S3, object storage), redirect to this directly.
|
||||
u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil)
|
||||
|
||||
if u != nil && err == nil {
|
||||
ctx.Redirect(u.String())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`, attach.CreatedUnix.AsTimePtr()) {
|
||||
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()
|
||||
|
||||
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, fr, httplib.ServeHeaderOptions{Filename: attach.Name})
|
||||
}
|
||||
|
||||
// GetAttachment serve attachments
|
||||
func GetAttachment(ctx *context.Context) {
|
||||
ServeAttachment(ctx, ctx.PathParam("uuid"))
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/languagestats"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/highlight"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
type blameRow struct {
|
||||
RowNumber int
|
||||
|
||||
Avatar template.HTML
|
||||
PreviousSha string
|
||||
PreviousShaURL string
|
||||
CommitURL string
|
||||
CommitMessage string
|
||||
CommitSince template.HTML
|
||||
|
||||
Code template.HTML
|
||||
EscapeStatus *charset.EscapeStatus
|
||||
}
|
||||
|
||||
// RefBlame render blame page
|
||||
func RefBlame(ctx *context.Context) {
|
||||
ctx.Data["IsBlame"] = true
|
||||
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
|
||||
|
||||
// Get current entry user currently looking at.
|
||||
if ctx.Repo.TreePath == "" {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
|
||||
return
|
||||
}
|
||||
|
||||
blob := entry.Blob()
|
||||
fileSize := blob.Size()
|
||||
ctx.Data["FileSize"] = fileSize
|
||||
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
|
||||
|
||||
tplName := tplRepoViewContent
|
||||
if !ctx.FormBool("only_content") {
|
||||
prepareHomeTreeSideBarSwitch(ctx)
|
||||
tplName = tplRepoView
|
||||
}
|
||||
|
||||
if fileSize >= setting.UI.MaxDisplayFileSize {
|
||||
ctx.Data["IsFileTooLarge"] = true
|
||||
ctx.HTML(http.StatusOK, tplName)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["NumLines"], err = blob.GetBlobLineCount(nil)
|
||||
if err != nil {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
|
||||
bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore"))
|
||||
result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, ctx.Repo.TreePath, bypassBlameIgnore)
|
||||
if err != nil {
|
||||
ctx.NotFound(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, tplName)
|
||||
}
|
||||
|
||||
type blameResult struct {
|
||||
Parts []*gitrepo.BlamePart
|
||||
UsesIgnoreRevs bool
|
||||
FaultyIgnoreRevsFile bool
|
||||
}
|
||||
|
||||
func performBlame(ctx *context.Context, repo *repo_model.Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) {
|
||||
objectFormat := ctx.Repo.GetObjectFormat()
|
||||
|
||||
blameReader, err := gitrepo.CreateBlameReader(ctx, objectFormat, repo, 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 = gitrepo.CreateBlameReader(ctx, objectFormat, repo, 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 *gitrepo.BlameReader, r *blameResult) error {
|
||||
r.UsesIgnoreRevs = br.UsesIgnoreRevs()
|
||||
|
||||
previousHelper := make(map[string]*gitrepo.BlamePart)
|
||||
|
||||
r.Parts = make([]*gitrepo.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 []*gitrepo.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(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.
|
||||
validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits)
|
||||
if err != nil {
|
||||
ctx.ServerError("ValidateCommitsWithEmails", err)
|
||||
return nil
|
||||
}
|
||||
for _, c := range validatedCommits {
|
||||
commitNames[c.ID.String()] = c
|
||||
}
|
||||
|
||||
return commitNames
|
||||
}
|
||||
|
||||
func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) {
|
||||
if commit.User != nil {
|
||||
br.Avatar = avatarUtils.Avatar(commit.User, 18)
|
||||
} else {
|
||||
br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18)
|
||||
}
|
||||
|
||||
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.MessageUTF8()
|
||||
br.CommitSince = templates.TimeSince(commit.Author.When)
|
||||
}
|
||||
|
||||
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*user_model.UserCommit) {
|
||||
language, err := languagestats.GetFileLanguage(ctx, 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)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
rows := make([]*blameRow, 0)
|
||||
avatarUtils := templates.NewAvatarUtils(ctx)
|
||||
rowNumber := 0 // will be 1-based
|
||||
for _, part := range blameParts {
|
||||
for partLineIdx, line := range part.Lines {
|
||||
rowNumber++
|
||||
|
||||
br := &blameRow{RowNumber: rowNumber}
|
||||
rows = append(rows, br)
|
||||
|
||||
if int64(buf.Len()) < setting.UI.MaxDisplayFileSize {
|
||||
buf.WriteString(line)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
if partLineIdx == 0 {
|
||||
renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
escapeStatus := &charset.EscapeStatus{}
|
||||
|
||||
bufContent := buf.Bytes()
|
||||
bufContent = charset.ToUTF8(bufContent, charset.ConvertOpts{})
|
||||
highlighted, _, lexerDisplayName := highlight.RenderCodeSlowGuess(path.Base(ctx.Repo.TreePath), language, util.UnsafeBytesToString(bufContent))
|
||||
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlighted)
|
||||
for i, br := range rows {
|
||||
var line template.HTML
|
||||
if i < len(unsafeLines) {
|
||||
line = template.HTML(util.UnsafeBytesToString(unsafeLines[i]))
|
||||
}
|
||||
br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale)
|
||||
escapeStatus = escapeStatus.Or(br.EscapeStatus)
|
||||
}
|
||||
|
||||
ctx.Data["EscapeStatus"] = escapeStatus
|
||||
ctx.Data["BlameRows"] = rows
|
||||
ctx.Data["LexerName"] = lexerDisplayName
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// 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"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/utils"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
release_service "gitea.dev/services/release"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
tplBranch templates.TplName = "repo/branch/list"
|
||||
)
|
||||
|
||||
// Branches render repository branch page
|
||||
func Branches(ctx *context.Context) {
|
||||
ctx.Data["Title"] = "Branches"
|
||||
ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx)
|
||||
ctx.Data["IsWriter"] = ctx.Repo.Permission.CanWrite(unit.TypeCode)
|
||||
ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror
|
||||
// TODO: Can be replaced by ctx.Repo.PullRequestCtx.CanCreateNewPull()
|
||||
ctx.Data["CanPull"] = ctx.Repo.Permission.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 := max(ctx.FormInt("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.Permission.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(branchesCount, pageSize, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
ctx.HTML(http.StatusOK, tplBranch)
|
||||
}
|
||||
|
||||
// DeleteBranchPost responses for delete merged branch
|
||||
func DeleteBranchPost(ctx *context.Context) {
|
||||
defer jsonRedirectBranches(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 jsonRedirectBranches(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 := gitrepo.Push(ctx, ctx.Repo.Repository, ctx.Repo.Repository, git.PushOptions{
|
||||
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 jsonRedirectBranches(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(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.Flash.Error(ctx.GetErrMsg())
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
if form.CreateTag {
|
||||
target := ctx.Repo.CommitID
|
||||
if ctx.Repo.RefFullName.IsBranch() {
|
||||
target = ctx.Repo.BranchName
|
||||
}
|
||||
err = release_service.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, target, form.NewBranchName, "")
|
||||
} else if ctx.Repo.RefFullName.IsBranch() {
|
||||
err = repo_service.CreateNewBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.BranchName, form.NewBranchName)
|
||||
} else {
|
||||
err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.CommitID, form.NewBranchName)
|
||||
}
|
||||
if err != nil {
|
||||
if release_service.IsErrProtectedTagName(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
|
||||
return
|
||||
}
|
||||
|
||||
if release_service.IsErrTagAlreadyExists(err) {
|
||||
e := err.(release_service.ErrTagAlreadyExists)
|
||||
ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
|
||||
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.RefTypeNameSubURL())
|
||||
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.RefTypeNameSubURL())
|
||||
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.EscapeFlashErrorString(e.Message),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("UpdatePullRequest.HTMLString", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Error(flashError)
|
||||
}
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
|
||||
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))
|
||||
}
|
||||
|
||||
func MergeUpstream(ctx *context.Context) {
|
||||
branchName := ctx.FormString("branch")
|
||||
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.JSONErrorNotFound()
|
||||
return
|
||||
} else if pull_service.IsErrMergeConflicts(err) {
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.merge_conflict"))
|
||||
return
|
||||
}
|
||||
ctx.ServerError("MergeUpstream", err)
|
||||
return
|
||||
}
|
||||
ctx.JSONRedirect("")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
contributors_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
tplCodeFrequency templates.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.Repository.DefaultBranch); err != nil {
|
||||
if errors.Is(err, contributors_service.ErrAwaitGeneration) {
|
||||
ctx.Status(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetContributorStats", err)
|
||||
} else {
|
||||
ctx.JSON(http.StatusOK, contributorStats["total"].Weeks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// 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"
|
||||
"strings"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/fileicon"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
"gitea.dev/services/context"
|
||||
git_service "gitea.dev/services/git"
|
||||
"gitea.dev/services/gitdiff"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
"gitea.dev/services/repository/gitgraph"
|
||||
)
|
||||
|
||||
const (
|
||||
tplCommits templates.TplName = "repo/commits"
|
||||
tplGraph templates.TplName = "repo/graph"
|
||||
tplGraphDiv templates.TplName = "repo/graph/div"
|
||||
tplCommitPage templates.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(nil)
|
||||
return
|
||||
}
|
||||
ctx.Data["PageIsViewCode"] = true
|
||||
|
||||
commitsCount := ctx.Repo.CommitsCount
|
||||
|
||||
page := max(ctx.FormInt("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"], err = processGitCommits(ctx, commits)
|
||||
if err != nil {
|
||||
ctx.ServerError("processGitCommits", err)
|
||||
return
|
||||
}
|
||||
commitIDs := make([]string, 0, len(commits))
|
||||
for _, c := range commits {
|
||||
commitIDs = append(commitIDs, c.ID.String())
|
||||
}
|
||||
commitsTagsMap, err := repo_model.FindTagsByCommitIDs(ctx, ctx.Repo.Repository.ID, commitIDs...)
|
||||
if err != nil {
|
||||
log.Error("FindTagsByCommitIDs: %v", err)
|
||||
ctx.Flash.Error(ctx.Tr("internal_error_skipped", "FindTagsByCommitIDs"))
|
||||
} else {
|
||||
ctx.Data["CommitsTagsMap"] = commitsTagsMap
|
||||
}
|
||||
ctx.Data["CommitCount"] = commitsCount
|
||||
|
||||
pager := context.NewPagination(commitsCount, pageSize, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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")
|
||||
|
||||
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{}
|
||||
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
|
||||
|
||||
divOnly := ctx.FormBool("div-only")
|
||||
queryParams := ctx.Req.URL.Query()
|
||||
queryParams.Del("div-only")
|
||||
paginator := context.NewPagination(graphCommitsCount, setting.UI.GraphMaxCommitNum, page, 5)
|
||||
paginator.AddParamFromQuery(queryParams)
|
||||
ctx.Data["Page"] = paginator
|
||||
if divOnly {
|
||||
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.RefTypeNameSubURL())
|
||||
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"], err = processGitCommits(ctx, commits)
|
||||
if err != nil {
|
||||
ctx.ServerError("processGitCommits", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Keyword"] = query
|
||||
if all {
|
||||
ctx.Data["All"] = true
|
||||
}
|
||||
ctx.HTML(http.StatusOK, tplCommits)
|
||||
}
|
||||
|
||||
// FileHistory show a file's reversions
|
||||
func FileHistory(ctx *context.Context) {
|
||||
if ctx.Repo.TreePath == "" {
|
||||
Commits(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
commitsCount, err := gitrepo.FileCommitsCount(ctx, ctx.Repo.Repository, ctx.Repo.RefFullName.ShortName(), ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
ctx.ServerError("FileCommitsCount", err)
|
||||
return
|
||||
} else if commitsCount == 0 {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
|
||||
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
|
||||
git.CommitsByFileAndRangeOptions{
|
||||
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
|
||||
File: ctx.Repo.TreePath,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("CommitsByFileAndRange", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Commits"], err = processGitCommits(ctx, commits)
|
||||
if err != nil {
|
||||
ctx.ServerError("processGitCommits", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
|
||||
ctx.Data["CommitCount"] = commitsCount
|
||||
|
||||
pager := context.NewPagination(commitsCount, setting.Git.CommitsRangeSize, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
ctx.HTML(http.StatusOK, tplCommits)
|
||||
}
|
||||
|
||||
func LoadBranchesAndTags(ctx *context.Context) {
|
||||
response, err := repo_service.LoadBranchesAndTags(ctx, ctx.Repo, ctx.PathParam("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.PathParam("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.PathParam("sha")
|
||||
|
||||
diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{
|
||||
BaseLink: ctx.Repo.RepoLink + "/blob_excerpt",
|
||||
DiffStyle: GetDiffViewStyle(ctx),
|
||||
AfterCommitID: commitID,
|
||||
}
|
||||
gitRepo := ctx.Repo.GitRepo
|
||||
var gitRepoStore gitrepo.Repository = ctx.Repo.Repository
|
||||
|
||||
if ctx.Data["PageIsWiki"] != nil {
|
||||
var err error
|
||||
gitRepoStore = ctx.Repo.Repository.WikiStorageRepo()
|
||||
gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, gitRepoStore)
|
||||
if err != nil {
|
||||
ctx.ServerError("Repo.GitRepo.GetCommit", err)
|
||||
return
|
||||
}
|
||||
diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt"
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetCommit(commitID)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.NotFound(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.GetDiffForRender(ctx, ctx.Repo.RepoLink, gitRepo, &gitdiff.DiffOptions{
|
||||
AfterCommitID: commitID,
|
||||
SkipTo: ctx.FormString("skip-to"),
|
||||
MaxLines: maxLines,
|
||||
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters,
|
||||
MaxFiles: maxFiles,
|
||||
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(GetWhitespaceBehavior(ctx)),
|
||||
}, files...)
|
||||
if err != nil {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
diffShortStat, err := gitdiff.GetDiffShortStat(ctx, gitRepoStore, gitRepo, "", commitID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDiffShortStat", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["DiffShortStat"] = diffShortStat
|
||||
|
||||
parents := make([]string, commit.ParentCount())
|
||||
for i := 0; i < commit.ParentCount(); i++ {
|
||||
sha, err := commit.ParentID(i)
|
||||
if err != nil {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
parents[i] = sha.String()
|
||||
}
|
||||
|
||||
ctx.Data["CommitID"] = commitID
|
||||
ctx.Data["AfterCommitID"] = commitID
|
||||
|
||||
var parentCommit *git.Commit
|
||||
var parentCommitID string
|
||||
if commit.ParentCount() > 0 {
|
||||
parentCommit, err = gitRepo.GetCommit(parents[0])
|
||||
if err != nil {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
parentCommitID = parentCommit.ID.String()
|
||||
}
|
||||
setCompareContext(ctx, parentCommit, commit, userName, repoName)
|
||||
ctx.Data["Title"] = commit.MessageTitle() + " · " + base.ShortSha(commitID)
|
||||
ctx.Data["Commit"] = commit
|
||||
ctx.Data["Diff"] = diff
|
||||
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
|
||||
|
||||
if !fileOnly {
|
||||
diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, parentCommitID, commitID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDiffTree", err)
|
||||
return
|
||||
}
|
||||
|
||||
renderedIconPool := fileicon.NewRenderedIconPool()
|
||||
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
|
||||
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
|
||||
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
|
||||
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
|
||||
}
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll)
|
||||
if err != nil {
|
||||
log.Error("GetLatestCommitStatus: %v", err)
|
||||
}
|
||||
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
|
||||
git_model.CommitStatusesHideActionsURL(ctx, statuses)
|
||||
}
|
||||
|
||||
ctx.Data["CommitStatus"] = git_model.CalcCommitStatus(statuses)
|
||||
ctx.Data["CommitStatuses"] = statuses
|
||||
|
||||
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
||||
ctx.Data["Verification"] = verification
|
||||
ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit)
|
||||
ctx.Data["Parents"] = parents
|
||||
ctx.Data["DiffNotAvailable"] = diffShortStat.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)
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)})
|
||||
htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{}))))
|
||||
ctx.Data["NoteRendered"], err = markup.PostProcessCommitMessage(rctx, htmlMessage)
|
||||
if err != nil {
|
||||
ctx.ServerError("PostProcessCommitMessage", err)
|
||||
return
|
||||
}
|
||||
} else if !git.IsErrNotExist(err) {
|
||||
log.Error("GetNote: %v", err)
|
||||
}
|
||||
|
||||
pr, _ := issues_model.GetPullRequestByMergedCommit(ctx, ctx.Repo.Repository.ID, commitID)
|
||||
if pr != nil {
|
||||
ctx.Data["MergedPRIssueNumber"] = pr.Index
|
||||
}
|
||||
|
||||
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.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo())
|
||||
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.PathParam("sha"),
|
||||
git.RawDiffType(ctx.PathParam("ext")),
|
||||
ctx.Resp,
|
||||
); err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.NotFound(errors.New("commit " + ctx.PathParam("sha") + " does not exist."))
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetRawDiff", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_model.SignCommitWithStatuses, error) {
|
||||
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ctx.Repo.Permission.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, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
git_model "gitea.dev/models/git"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/services/context"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
type RecentBranchesPromptDataStruct struct {
|
||||
RecentlyPushedNewBranches []*git_model.RecentlyPushedNewBranch
|
||||
}
|
||||
|
||||
func prepareRecentlyPushedNewBranches(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
return
|
||||
}
|
||||
if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil {
|
||||
log.Error("GetBaseRepo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
opts := git_model.FindRecentlyPushedNewBranchesOptions{
|
||||
Repo: ctx.Repo.Repository,
|
||||
BaseRepo: ctx.Repo.Repository,
|
||||
}
|
||||
if ctx.Repo.Repository.IsFork {
|
||||
opts.BaseRepo = ctx.Repo.Repository.BaseRepo
|
||||
}
|
||||
|
||||
baseRepoPerm, err := access_model.GetDoerRepoPermission(ctx, opts.BaseRepo, ctx.Doer)
|
||||
if err != nil {
|
||||
log.Error("GetDoerRepoPermission: %v", err)
|
||||
return
|
||||
}
|
||||
if !opts.Repo.CanContentChange() || !opts.BaseRepo.CanContentChange() {
|
||||
return
|
||||
}
|
||||
if !opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) || !baseRepoPerm.CanRead(unit_model.TypePullRequests) {
|
||||
return
|
||||
}
|
||||
|
||||
var finalBranches []*git_model.RecentlyPushedNewBranch
|
||||
branches, err := git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts)
|
||||
if err != nil {
|
||||
log.Error("FindRecentlyPushedNewBranches failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, branch := range branches {
|
||||
divergingInfo, err := repo_service.GetBranchDivergingInfo(ctx,
|
||||
branch.BranchRepo, branch.BranchName, // "base" repo for diverging info
|
||||
opts.BaseRepo, opts.BaseRepo.DefaultBranch, // "head" repo for diverging info
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("GetBranchDivergingInfo failed: %v", err)
|
||||
continue
|
||||
}
|
||||
branchRepoHasNewCommits := divergingInfo.BaseHasNewCommits
|
||||
baseRepoCommitsBehind := divergingInfo.HeadCommitsBehind
|
||||
if branchRepoHasNewCommits || baseRepoCommitsBehind > 0 {
|
||||
finalBranches = append(finalBranches, branch)
|
||||
}
|
||||
}
|
||||
if len(finalBranches) > 0 {
|
||||
ctx.Data["RecentBranchesPromptData"] = RecentBranchesPromptDataStruct{finalBranches}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
gocontext "context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/charset"
|
||||
csv_module "gitea.dev/modules/csv"
|
||||
"gitea.dev/modules/fileicon"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/context/upload"
|
||||
git_service "gitea.dev/services/git"
|
||||
"gitea.dev/services/gitdiff"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
const (
|
||||
tplCompare templates.TplName = "repo/diff/compare"
|
||||
tplBlobExcerpt templates.TplName = "repo/diff/blob_excerpt"
|
||||
tplDiffBox templates.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
|
||||
}
|
||||
var closer io.Closer = reader
|
||||
csvReader, err := csv_module.CreateReaderAndDetermineDelimiter(ctx, charset.ToUTF8WithFallbackReader(reader, charset.ConvertOpts{}))
|
||||
return csvReader, closer, err
|
||||
}
|
||||
|
||||
baseReader, baseBlobCloser, err := csvReaderFromCommit(markup.NewRenderContext(ctx).WithRelativePath(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.NewRenderContext(ctx).WithRelativePath(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, ""}
|
||||
}
|
||||
}
|
||||
|
||||
type comparePageInfoType struct {
|
||||
compareInfo *git_service.CompareInfo
|
||||
nothingToCompare bool
|
||||
allowCreatePull bool
|
||||
}
|
||||
|
||||
func newComparePageInfo() *comparePageInfoType {
|
||||
return &comparePageInfoType{}
|
||||
}
|
||||
|
||||
// parseCompareInfo parse compare info between two commit for preparing comparing references
|
||||
func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context) error {
|
||||
baseRepo := ctx.Repo.Repository
|
||||
fileOnly := ctx.FormBool("file-only")
|
||||
|
||||
// 1 Parse compare router param
|
||||
compareReq := common.ParseCompareRouterParam(ctx.PathParam("*"))
|
||||
|
||||
// remove the check when we support compare with carets
|
||||
if compareReq.BaseOriRefSuffix != "" {
|
||||
return util.NewInvalidArgumentErrorf("unsupported comparison syntax: ref with suffix")
|
||||
}
|
||||
|
||||
// 2 get repository and owner for head
|
||||
headOwner, headRepo, err := common.GetHeadOwnerAndRepo(ctx, baseRepo, compareReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3 permission check
|
||||
// base repository's code unit read permission check has been done on web.go
|
||||
permBase := ctx.Repo.Permission
|
||||
|
||||
// If we're not merging from the same repo:
|
||||
isSameRepo := baseRepo.ID == headRepo.ID
|
||||
if !isSameRepo {
|
||||
// Assert ctx.Doer has permission to read headRepo's codes
|
||||
permHead, err := access_model.GetDoerRepoPermission(ctx, headRepo, ctx.Doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !permHead.CanRead(unit.TypeCode) {
|
||||
return util.NewNotExistErrorf("") // permission: no error message for end users
|
||||
}
|
||||
ctx.Data["CanWriteToHeadRepo"] = permHead.CanWrite(unit.TypeCode)
|
||||
}
|
||||
|
||||
// 4 get base and head refs
|
||||
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx))
|
||||
headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)
|
||||
|
||||
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName)
|
||||
if baseRef == "" {
|
||||
return util.NewNotExistErrorf("no base ref: %s", baseRefName)
|
||||
}
|
||||
headGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, headRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headRef := headGitRepo.UnstableGuessRefByShortName(headRefName)
|
||||
if headRef == "" {
|
||||
return util.NewNotExistErrorf("no head ref: %s", headRefName)
|
||||
}
|
||||
|
||||
ctx.Data["BaseName"] = baseRepo.OwnerName
|
||||
ctx.Data["BaseBranch"] = baseRef.ShortName() // for legacy templates
|
||||
ctx.Data["HeadUser"] = headOwner
|
||||
ctx.Data["HeadBranch"] = headRef.ShortName() // for legacy templates
|
||||
ctx.Data["IsPull"] = true
|
||||
|
||||
context.InitRepoPullRequestCtx(ctx, baseRepo, headRepo)
|
||||
|
||||
// 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 && !repo_model.IsErrRepoNotExist(err) {
|
||||
return err
|
||||
} else if err == nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["HeadRepo"] = headRepo
|
||||
ctx.Data["BaseCompareRepo"] = ctx.Repo.Repository
|
||||
|
||||
// If we have a rootRepo, and it's different from:
|
||||
// 1. the computed base
|
||||
// 2. the computed head
|
||||
// then get the branches of it
|
||||
if rootRepo != nil &&
|
||||
rootRepo.ID != headRepo.ID &&
|
||||
rootRepo.ID != baseRepo.ID {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
ctx.Data["RootRepoBranches"] = branches
|
||||
ctx.Data["RootRepoTags"] = tags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a ownForkRepo, and it's different from:
|
||||
// 1. The computed base
|
||||
// 2. The computed head
|
||||
// 3. The rootRepo (if we have one)
|
||||
// then get the branches from it.
|
||||
if ownForkRepo != nil &&
|
||||
ownForkRepo.ID != headRepo.ID &&
|
||||
ownForkRepo.ID != baseRepo.ID &&
|
||||
(rootRepo == nil || ownForkRepo.ID != rootRepo.ID) {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
ctx.Data["OwnForkRepoBranches"] = branches
|
||||
ctx.Data["OwnForkRepoTags"] = tags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compareInfo, err := git_service.GetCompareInfo(ctx, baseRepo, headRepo, headGitRepo, baseRef, headRef, compareReq.DirectComparison(), fileOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Treat as pull request if both references are branches
|
||||
cpi.allowCreatePull = baseRef.IsBranch() && headRef.IsBranch() && permBase.CanReadIssuesOrPulls(true)
|
||||
cpi.allowCreatePull = cpi.allowCreatePull && compareInfo.CompareBase != ""
|
||||
cpi.compareInfo = &compareInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoTitleFromBranchName humanizes a branch name into a PR title.
|
||||
func autoTitleFromBranchName(name string) string {
|
||||
var buf strings.Builder
|
||||
var prevIsSpace bool
|
||||
runes := []rune(name)
|
||||
for i, r := range runes {
|
||||
isSpace := unicode.IsSpace(r)
|
||||
if r == '-' || r == '_' || isSpace {
|
||||
if !prevIsSpace {
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
prevIsSpace = true
|
||||
continue
|
||||
}
|
||||
if !prevIsSpace && unicode.IsUpper(r) {
|
||||
needSpace := i > 0 && unicode.IsLower(runes[i-1]) || i < len(runes)-1 && unicode.IsLower(runes[i+1])
|
||||
if needSpace {
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
buf.WriteRune(unicode.ToLower(r))
|
||||
prevIsSpace = isSpace
|
||||
}
|
||||
out := strings.TrimSpace(buf.String())
|
||||
if out == "" {
|
||||
return out
|
||||
}
|
||||
outRunes := []rune(out)
|
||||
outRunes[0] = unicode.ToUpper(outRunes[0])
|
||||
return string(outRunes)
|
||||
}
|
||||
|
||||
func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses, defaultTitleSource string) (title, content string) {
|
||||
useFirstCommitAsTitle := len(commits) == 1 || (defaultTitleSource == setting.RepoPRTitleSourceFirstCommit && len(commits) > 0)
|
||||
if useFirstCommitAsTitle {
|
||||
// the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one
|
||||
c := commits[len(commits)-1]
|
||||
title = c.UserCommit.MessageTitle()
|
||||
} else {
|
||||
title = autoTitleFromBranchName(ci.HeadRef.ShortName())
|
||||
}
|
||||
|
||||
if len(commits) == 1 {
|
||||
c := commits[0]
|
||||
content = c.MessageBody()
|
||||
}
|
||||
|
||||
var titleTrailer string
|
||||
// TODO: 255 doesn't seem to be a good limit for title, just keep the old behavior
|
||||
title, titleTrailer = util.EllipsisDisplayStringX(title, 255)
|
||||
if titleTrailer != "" {
|
||||
if content != "" {
|
||||
content = titleTrailer + "\n\n" + content
|
||||
} else {
|
||||
content = titleTrailer + "\n"
|
||||
}
|
||||
}
|
||||
return title, content
|
||||
}
|
||||
|
||||
// prepareCompareDiff renders compare diff page. TODO: need to refactor it and other "compare diff" related functions together
|
||||
func (cpi *comparePageInfoType) prepareCompareDiff(ctx *context.Context, whitespaceBehavior gitcmd.TrustedCmdArgs) {
|
||||
ci := cpi.compareInfo
|
||||
if ci.CompareBase == "" {
|
||||
cpi.nothingToCompare = true
|
||||
return
|
||||
}
|
||||
repo := ctx.Repo.Repository
|
||||
headCommitID := ci.HeadCommitID
|
||||
|
||||
ctx.Data["CommitRepoLink"] = ci.HeadRepo.Link()
|
||||
ctx.Data["BeforeCommitID"] = ci.CompareBase
|
||||
ctx.Data["AfterCommitID"] = headCommitID
|
||||
|
||||
// follow GitHub's behavior: autofill the form and expand
|
||||
newPrFormTitle := ctx.FormTrim("title")
|
||||
newPrFormBody := ctx.FormTrim("body")
|
||||
ctx.Data["ExpandNewPrForm"] = ctx.FormBool("expand") || ctx.FormBool("quick_pull") || newPrFormTitle != "" || newPrFormBody != ""
|
||||
ctx.Data["TitleQuery"] = newPrFormTitle
|
||||
ctx.Data["BodyQuery"] = newPrFormBody
|
||||
|
||||
if headCommitID == ci.CompareBase {
|
||||
config := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
|
||||
// if auto-detect manual merge, an empty PR will be closed immediately because it is already on base branch
|
||||
supportEmptyPr := !config.AutodetectManualMerge
|
||||
acrossRepoPr := !ci.IsSameRef()
|
||||
ctx.Data["AllowEmptyPr"] = supportEmptyPr && acrossRepoPr
|
||||
|
||||
cpi.nothingToCompare = true
|
||||
return
|
||||
}
|
||||
|
||||
beforeCommitID := ci.CompareBase
|
||||
|
||||
maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles
|
||||
files := ctx.FormStrings("files")
|
||||
if len(files) == 2 || len(files) == 1 {
|
||||
maxLines, maxFiles = -1, -1
|
||||
}
|
||||
|
||||
fileOnly := ctx.FormBool("file-only")
|
||||
|
||||
diff, err := gitdiff.GetDiffForRender(ctx, ci.HeadRepo.Link(), 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("GetDiff", err)
|
||||
return
|
||||
}
|
||||
diffShortStat, err := gitdiff.GetDiffShortStat(ctx, ci.HeadRepo, ci.HeadGitRepo, beforeCommitID, headCommitID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDiffShortStat", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["DiffShortStat"] = diffShortStat
|
||||
ctx.Data["Diff"] = diff
|
||||
ctx.Data["DiffBlobExcerptData"] = &gitdiff.DiffBlobExcerptData{
|
||||
BaseLink: ci.HeadRepo.Link() + "/blob_excerpt",
|
||||
DiffStyle: GetDiffViewStyle(ctx),
|
||||
AfterCommitID: headCommitID,
|
||||
}
|
||||
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
|
||||
|
||||
if !fileOnly {
|
||||
diffTree, err := gitdiff.GetDiffTree(ctx, ci.HeadGitRepo, false, beforeCommitID, headCommitID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDiffTree", err)
|
||||
return
|
||||
}
|
||||
|
||||
renderedIconPool := fileicon.NewRenderedIconPool()
|
||||
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
|
||||
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
|
||||
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
|
||||
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
|
||||
}
|
||||
|
||||
headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCommit", err)
|
||||
return
|
||||
}
|
||||
|
||||
baseGitRepo := ctx.Repo.GitRepo
|
||||
|
||||
beforeCommit, err := baseGitRepo.GetCommit(beforeCommitID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCommit", err)
|
||||
return
|
||||
}
|
||||
|
||||
commits, err := processGitCommits(ctx, ci.Commits)
|
||||
if err != nil {
|
||||
ctx.ServerError("processGitCommits", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Commits"] = commits
|
||||
ctx.Data["CommitCount"] = len(commits)
|
||||
|
||||
ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits, setting.Repository.PullRequest.DefaultTitleSource)
|
||||
|
||||
setCompareContext(ctx, beforeCommit, headCommit, ci.HeadRepo.OwnerName, repo.Name)
|
||||
}
|
||||
|
||||
func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repository) (branches, tags []string, err error) {
|
||||
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 = repo_model.GetTagNamesByRepoID(ctx, repo.ID)
|
||||
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) {
|
||||
comparePageInfo := newComparePageInfo()
|
||||
err := comparePageInfo.parseCompareInfo(ctx)
|
||||
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
} else if err != nil {
|
||||
ctx.ServerError("ParseCompareInfo", err)
|
||||
return
|
||||
}
|
||||
ci := comparePageInfo.compareInfo
|
||||
ctx.Data["PageIsViewCode"] = true
|
||||
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
|
||||
ctx.Data["CompareInfo"] = ci
|
||||
|
||||
// TODO: need to refactor "prepare compare" related functions together
|
||||
comparePageInfo.prepareCompareDiff(ctx, gitdiff.GetWhitespaceFlag(GetWhitespaceBehavior(ctx)))
|
||||
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, headTags, err := getBranchesAndTagsForRepo(ctx, ci.HeadRepo)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBranchesAndTagsForRepo", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["HeadBranches"] = headBranches
|
||||
ctx.Data["HeadTags"] = headTags
|
||||
|
||||
// For compare repo branches
|
||||
PrepareBranchList(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if ci.CompareBase != "" {
|
||||
comparePageInfo.prepareCreatePullRequestPage(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ctx.Flash.Error(ctx.Tr("repo.pulls.no_common_history"), true)
|
||||
ctx.Data["CommitCount"] = 0
|
||||
}
|
||||
ctx.Data["PageIsComparePull"] = comparePageInfo.allowCreatePull
|
||||
ctx.Data["IsNothingToCompare"] = comparePageInfo.nothingToCompare
|
||||
ctx.HTML(http.StatusOK, tplCompare)
|
||||
}
|
||||
|
||||
func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Context) {
|
||||
ci := cpi.compareInfo
|
||||
if cpi.allowCreatePull {
|
||||
pr, err := issues_model.GetUnmergedPullRequest(ctx, ci.HeadRepo.ID, ctx.Repo.Repository.ID, ci.HeadRef.ShortName(), ci.BaseRef.ShortName(), 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
|
||||
return
|
||||
}
|
||||
|
||||
if !cpi.nothingToCompare {
|
||||
// Setup information for new form.
|
||||
pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, true)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
|
||||
if len(templateErrs) > 0 {
|
||||
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
beforeCommitID := cpi.compareInfo.CompareBase
|
||||
afterCommitID := cpi.compareInfo.HeadCommitID
|
||||
|
||||
ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + ci.CompareSeparator + base.ShortSha(afterCommitID)
|
||||
|
||||
ctx.Data["IsDiffCompare"] = 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 prepended to the template body.
|
||||
if templateContent, ok := ctx.Data[pullRequestTemplateKey].(string); ok && templateContent != "" {
|
||||
// Re-use 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.Permission.CanWrite(unit.TypeProjects)
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypePullRequests)
|
||||
|
||||
prConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
|
||||
ctx.Data["AllowMaintainerEdit"] = prConfig.DefaultAllowMaintainerEdit
|
||||
}
|
||||
|
||||
// attachCommentsToLines attaches comments to their corresponding diff lines
|
||||
func attachCommentsToLines(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) {
|
||||
for _, line := range section.Lines {
|
||||
if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok {
|
||||
line.Comments = append(line.Comments, comments...)
|
||||
}
|
||||
if comments, ok := lineComments[int64(line.RightIdx)]; ok {
|
||||
line.Comments = append(line.Comments, comments...)
|
||||
}
|
||||
sort.SliceStable(line.Comments, func(i, j int) bool {
|
||||
return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// attachHiddenCommentIDs calculates and attaches hidden comment IDs to expand buttons
|
||||
func attachHiddenCommentIDs(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) {
|
||||
for _, line := range section.Lines {
|
||||
gitdiff.FillHiddenCommentIDsForDiffLine(line, lineComments)
|
||||
}
|
||||
}
|
||||
|
||||
// ExcerptBlob render blob excerpt contents
|
||||
func ExcerptBlob(ctx *context.Context) {
|
||||
commitID := ctx.PathParam("sha")
|
||||
opts := gitdiff.BlobExcerptOptions{
|
||||
LastLeft: ctx.FormInt("last_left"),
|
||||
LastRight: ctx.FormInt("last_right"),
|
||||
LeftIndex: ctx.FormInt("left"),
|
||||
RightIndex: ctx.FormInt("right"),
|
||||
LeftHunkSize: ctx.FormInt("left_hunk_size"),
|
||||
RightHunkSize: ctx.FormInt("right_hunk_size"),
|
||||
Direction: ctx.FormString("direction"),
|
||||
Language: ctx.FormString("filelang"),
|
||||
}
|
||||
filePath := ctx.FormString("path")
|
||||
gitRepo := ctx.Repo.GitRepo
|
||||
|
||||
diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{
|
||||
BaseLink: ctx.Repo.RepoLink + "/blob_excerpt",
|
||||
DiffStyle: GetDiffViewStyle(ctx),
|
||||
AfterCommitID: commitID,
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsWiki"] == true {
|
||||
var err error
|
||||
gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository.WikiStorageRepo())
|
||||
if err != nil {
|
||||
ctx.ServerError("OpenRepository", err)
|
||||
return
|
||||
}
|
||||
diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt"
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetCommit(commitID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCommit", err)
|
||||
return
|
||||
}
|
||||
blob, err := commit.Tree.GetBlobByPath(filePath)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBlobByPath", err)
|
||||
return
|
||||
}
|
||||
reader, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
ctx.ServerError("DataAsync", err)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
section, err := gitdiff.BuildBlobExcerptDiffSection(filePath, reader, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("BuildBlobExcerptDiffSection", err)
|
||||
return
|
||||
}
|
||||
|
||||
diffBlobExcerptData.PullIssueIndex = ctx.FormInt64("pull_issue_index")
|
||||
if diffBlobExcerptData.PullIssueIndex > 0 {
|
||||
if !ctx.Repo.Permission.CanRead(unit.TypePullRequests) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, diffBlobExcerptData.PullIssueIndex)
|
||||
if err != nil {
|
||||
log.Error("GetIssueByIndex error: %v", err)
|
||||
} else if issue.IsPull {
|
||||
// FIXME: DIFF-CONVERSATION-DATA: the following data assignment is fragile
|
||||
ctx.Data["Issue"] = issue
|
||||
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
|
||||
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
|
||||
}
|
||||
// and "diff/comment_form.tmpl" (reply comment) needs them
|
||||
ctx.Data["PageIsPullFiles"] = true
|
||||
ctx.Data["AfterCommitID"] = diffBlobExcerptData.AfterCommitID
|
||||
|
||||
allComments, err := issues_model.FetchCodeComments(ctx, issue, ctx.Doer, ctx.FormBool("show_outdated"))
|
||||
if err != nil {
|
||||
log.Error("FetchCodeComments error: %v", err)
|
||||
} else {
|
||||
if lineComments, ok := allComments[filePath]; ok {
|
||||
attachCommentsToLines(section, lineComments)
|
||||
attachHiddenCommentIDs(section, lineComments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["section"] = section
|
||||
ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath)
|
||||
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
|
||||
|
||||
ctx.HTML(http.StatusOK, tplBlobExcerpt)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
git_service "gitea.dev/services/git"
|
||||
"gitea.dev/services/gitdiff"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAttachCommentsToLines(t *testing.T) {
|
||||
section := &gitdiff.DiffSection{
|
||||
Lines: []*gitdiff.DiffLine{
|
||||
{LeftIdx: 5, RightIdx: 10},
|
||||
{LeftIdx: 6, RightIdx: 11},
|
||||
},
|
||||
}
|
||||
|
||||
lineComments := map[int64][]*issues_model.Comment{
|
||||
-5: {{ID: 100, CreatedUnix: 1000}}, // left side comment
|
||||
10: {{ID: 200, CreatedUnix: 2000}}, // right side comment
|
||||
11: {{ID: 300, CreatedUnix: 1500}, {ID: 301, CreatedUnix: 2500}}, // multiple comments
|
||||
}
|
||||
|
||||
attachCommentsToLines(section, lineComments)
|
||||
|
||||
// First line should have left and right comments
|
||||
assert.Len(t, section.Lines[0].Comments, 2)
|
||||
assert.Equal(t, int64(100), section.Lines[0].Comments[0].ID)
|
||||
assert.Equal(t, int64(200), section.Lines[0].Comments[1].ID)
|
||||
|
||||
// Second line should have two comments, sorted by creation time
|
||||
assert.Len(t, section.Lines[1].Comments, 2)
|
||||
assert.Equal(t, int64(300), section.Lines[1].Comments[0].ID)
|
||||
assert.Equal(t, int64(301), section.Lines[1].Comments[1].ID)
|
||||
}
|
||||
|
||||
func TestNewPullRequestTitleContent(t *testing.T) {
|
||||
ci := &git_service.CompareInfo{HeadRef: "refs/heads/head-branch"}
|
||||
|
||||
mockCommit := func(msg string) *git_model.SignCommitWithStatuses {
|
||||
return &git_model.SignCommitWithStatuses{
|
||||
SignCommit: &asymkey_model.SignCommit{
|
||||
UserCommit: &user_model.UserCommit{
|
||||
Commit: &git.Commit{
|
||||
CommitMessage: git.CommitMessage{MessageRaw: msg},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// no commit
|
||||
title, content := prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceAuto)
|
||||
assert.Equal(t, "Head branch", title)
|
||||
assert.Empty(t, content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceFirstCommit)
|
||||
assert.Equal(t, "Head branch", title)
|
||||
assert.Empty(t, content)
|
||||
|
||||
// single commit
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceAuto)
|
||||
assert.Equal(t, "single-commit-title", title)
|
||||
assert.Equal(t, "body", content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceFirstCommit)
|
||||
assert.Equal(t, "single-commit-title", title)
|
||||
assert.Equal(t, "body", content)
|
||||
|
||||
// multiple commits
|
||||
commits := []*git_model.SignCommitWithStatuses{
|
||||
// ordered from newest to oldest
|
||||
mockCommit("title2\nbody2"),
|
||||
mockCommit("title1\nbody1"),
|
||||
}
|
||||
title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceAuto)
|
||||
assert.Equal(t, "Head branch", title)
|
||||
assert.Empty(t, content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceFirstCommit)
|
||||
assert.Equal(t, "title1", title)
|
||||
assert.Empty(t, content)
|
||||
|
||||
// title string handling
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-" + strings.Repeat("a", 255))}, setting.RepoPRTitleSourceFirstCommit)
|
||||
assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title)
|
||||
assert.Equal(t, "…aaaaaaaaa\n", content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title \xf0\nbody \xf0")}, setting.RepoPRTitleSourceFirstCommit)
|
||||
assert.Equal(t, "title ð", title)
|
||||
assert.Equal(t, "body ð", content)
|
||||
}
|
||||
|
||||
func TestAutoTitleFromBranchName(t *testing.T) {
|
||||
cases := []struct {
|
||||
branch string
|
||||
want string
|
||||
}{
|
||||
{"fix/the-bug", "Fix/the bug"},
|
||||
{"Already-Capitalized", "Already capitalized"},
|
||||
{"ALL-CAPS-BRANCH", "All caps branch"},
|
||||
{"FixHTMLBug", "Fix html bug"},
|
||||
{"MixedCase-Name", "Mixed case name"},
|
||||
{"fooBar-baz", "Foo bar baz"},
|
||||
{"foo/BAR", "Foo/bar"},
|
||||
{"_leading-underscore", "Leading underscore"},
|
||||
{"CamelCase", "Camel case"},
|
||||
{"foo--double-dash", "Foo double dash"},
|
||||
{"123-fix", "123 fix"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.want, autoTitleFromBranchName(c.branch), "branch: %q", c.branch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
contributors_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
tplContributors templates.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.Repository.DefaultBranch); err != nil {
|
||||
if errors.Is(err, contributors_service.ErrAwaitGeneration) {
|
||||
ctx.Status(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetContributorStats", err)
|
||||
} else {
|
||||
ctx.JSON(http.StatusOK, contributorStats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/httpcache"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func checkDownloadTokenScope(ctx *context.Context) bool {
|
||||
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
|
||||
return !ctx.Written()
|
||||
}
|
||||
|
||||
// ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary
|
||||
func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Time) error {
|
||||
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
|
||||
return nil
|
||||
}
|
||||
|
||||
lfsPointerBuf, err := blob.GetBlobBytes(lfs.MetaFileMaxSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pointer, _ := lfs.ReadPointerFromBuffer(lfsPointerBuf)
|
||||
if pointer.IsValid() {
|
||||
meta, _ := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid)
|
||||
if meta == nil {
|
||||
return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified)
|
||||
}
|
||||
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`, meta.UpdatedUnix.AsTimePtr()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if setting.LFS.Storage.ServeDirect() {
|
||||
// If we have a signed url (S3, object storage, blob storage), redirect to this directly.
|
||||
u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
|
||||
if u != nil && err == nil {
|
||||
ctx.Redirect(u.String())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
lfsDataFile, err := lfs.ReadMetaObject(meta.Pointer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lfsDataFile.Close()
|
||||
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath})
|
||||
return nil
|
||||
}
|
||||
|
||||
return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified)
|
||||
}
|
||||
|
||||
func getBlobForEntry(ctx *context.Context) (*git.Blob, *time.Time) {
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetTreeEntryByPath", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if entry.IsDir() || entry.IsSubModule() {
|
||||
ctx.NotFound(nil)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
latestCommit, err := ctx.Repo.GitRepo.GetTreePathLatestCommit(ctx.Repo.Commit.ID.String(), ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetTreePathLatestCommit", err)
|
||||
return nil, nil
|
||||
}
|
||||
lastModified := &latestCommit.Committer.When
|
||||
|
||||
return entry.Blob(), lastModified
|
||||
}
|
||||
|
||||
// SingleDownload download a file by repos path
|
||||
func SingleDownload(ctx *context.Context) {
|
||||
if !checkDownloadTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
blob, lastModified := getBlobForEntry(ctx)
|
||||
if blob == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.ServeBlob(ctx.Base, ctx.Repo.Repository, 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) {
|
||||
if !checkDownloadTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
if !checkDownloadTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha"))
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.NotFound(nil)
|
||||
} else {
|
||||
ctx.ServerError("GetBlob", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err = common.ServeBlob(ctx.Base, ctx.Repo.Repository, 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) {
|
||||
if !checkDownloadTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha"))
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.NotFound(nil)
|
||||
} else {
|
||||
ctx.ServerError("GetBlob", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err = ServeBlobOrLFS(ctx, blob, nil); err != nil {
|
||||
ctx.ServerError("ServeBlob", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/issues"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/context/upload"
|
||||
"gitea.dev/services/forms"
|
||||
files_service "gitea.dev/services/repository/files"
|
||||
)
|
||||
|
||||
const (
|
||||
tplEditFile templates.TplName = "repo/editor/edit"
|
||||
tplEditDiffPreview templates.TplName = "repo/editor/diff_preview"
|
||||
tplDeleteFile templates.TplName = "repo/editor/delete"
|
||||
tplUploadFile templates.TplName = "repo/editor/upload"
|
||||
tplPatchFile templates.TplName = "repo/editor/patch"
|
||||
tplCherryPick templates.TplName = "repo/editor/cherry_pick"
|
||||
|
||||
editorCommitChoiceDirect string = "direct"
|
||||
editorCommitChoiceNewBranch string = "commit-to-new-branch"
|
||||
)
|
||||
|
||||
func prepareEditorPage(ctx *context.Context, editorAction string) *context.CommitFormOptions {
|
||||
prepareHomeTreeSideBarSwitch(ctx)
|
||||
return prepareEditorPageFormOptions(ctx, editorAction)
|
||||
}
|
||||
|
||||
func prepareEditorPageFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
|
||||
cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
|
||||
if cleanedTreePath != ctx.Repo.TreePath {
|
||||
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath))
|
||||
if ctx.Req.URL.RawQuery != "" {
|
||||
redirectTo += "?" + ctx.Req.URL.RawQuery
|
||||
}
|
||||
ctx.Redirect(redirectTo)
|
||||
return nil
|
||||
}
|
||||
|
||||
commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
|
||||
if err != nil {
|
||||
ctx.ServerError("PrepareCommitFormOptions", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if commitFormOptions.NeedFork {
|
||||
ForkToEdit(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() {
|
||||
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable")
|
||||
ctx.NotFound(nil)
|
||||
}
|
||||
|
||||
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
|
||||
ctx.Data["TreePath"] = ctx.Repo.TreePath
|
||||
ctx.Data["CommitFormOptions"] = commitFormOptions
|
||||
|
||||
// for online editor
|
||||
ctx.Data["IsEditingFileOnly"] = ctx.FormString("return_uri") != ""
|
||||
ctx.Data["ReturnURI"] = ctx.FormString("return_uri")
|
||||
|
||||
// form fields
|
||||
ctx.Data["commit_summary"] = ""
|
||||
ctx.Data["commit_message"] = ""
|
||||
ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
|
||||
ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo)
|
||||
ctx.Data["last_commit"] = ctx.Repo.CommitID
|
||||
return commitFormOptions
|
||||
}
|
||||
|
||||
func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) {
|
||||
// show the tree path fields in the "breadcrumb" and help users to edit the target tree path
|
||||
ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(strings.TrimPrefix(treePath, "/"))
|
||||
}
|
||||
|
||||
type preparedEditorCommitForm[T any] struct {
|
||||
form T
|
||||
commonForm *forms.CommitCommonForm
|
||||
CommitFormOptions *context.CommitFormOptions
|
||||
OldBranchName string
|
||||
NewBranchName string
|
||||
GitCommitter *files_service.IdentityOptions
|
||||
}
|
||||
|
||||
func (f *preparedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string {
|
||||
commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage)
|
||||
if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" {
|
||||
commitMessage += "\n\n" + body
|
||||
}
|
||||
return commitMessage
|
||||
}
|
||||
|
||||
func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] {
|
||||
form := web.GetForm(ctx).(T)
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return nil
|
||||
}
|
||||
|
||||
commonForm := form.GetCommitCommonForm()
|
||||
commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath)
|
||||
|
||||
commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
|
||||
if err != nil {
|
||||
ctx.ServerError("PrepareCommitFormOptions", err)
|
||||
return nil
|
||||
}
|
||||
if commitFormOptions.NeedFork {
|
||||
// It shouldn't happen, because we should have done the checks in the "GET" request. But just in case.
|
||||
ctx.JSONError(ctx.Locale.TrString("error.not_found"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// check commit behavior
|
||||
fromBaseBranch := ctx.FormString("from_base_branch")
|
||||
commitToNewBranch := commonForm.CommitChoice == editorCommitChoiceNewBranch || fromBaseBranch != ""
|
||||
targetBranchName := util.Iif(commitToNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName)
|
||||
if targetBranchName == ctx.Repo.BranchName && !commitFormOptions.CanCommitToBranch {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName))
|
||||
return nil
|
||||
}
|
||||
|
||||
if !issues.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, targetBranchName, ctx.Doer) {
|
||||
ctx.NotFound(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Committer user info
|
||||
gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, commonForm.CommitEmail)
|
||||
if !valid {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.invalid_commit_email"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if commitToNewBranch {
|
||||
// if target branch exists, we should stop
|
||||
targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName)
|
||||
if err != nil {
|
||||
ctx.ServerError("IsBranchExist", err)
|
||||
return nil
|
||||
} else if targetBranchExists {
|
||||
if fromBaseBranch != "" {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.fork_branch_exists", targetBranchName))
|
||||
} else {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
oldBranchName := ctx.Repo.BranchName
|
||||
if fromBaseBranch != "" {
|
||||
err = editorPushBranchToForkedRepository(ctx, ctx.Doer, ctx.Repo.Repository.BaseRepo, fromBaseBranch, commitFormOptions.TargetRepo, targetBranchName)
|
||||
if err != nil {
|
||||
log.Error("Unable to editorPushBranchToForkedRepository: %v", err)
|
||||
ctx.JSONError(ctx.Tr("repo.editor.fork_failed_to_push_branch", targetBranchName))
|
||||
return nil
|
||||
}
|
||||
// we have pushed the base branch as the new branch, now we need to commit the changes directly to the new branch
|
||||
oldBranchName = targetBranchName
|
||||
}
|
||||
|
||||
return &preparedEditorCommitForm[T]{
|
||||
form: form,
|
||||
commonForm: commonForm,
|
||||
CommitFormOptions: commitFormOptions,
|
||||
OldBranchName: oldBranchName,
|
||||
NewBranchName: targetBranchName,
|
||||
GitCommitter: gitCommitter,
|
||||
}
|
||||
}
|
||||
|
||||
// redirectForCommitChoice redirects after committing the edit to a branch
|
||||
func redirectForCommitChoice[T any](ctx *context.Context, parsed *preparedEditorCommitForm[T], treePath string) {
|
||||
// when editing a file in a PR, it should return to the origin location
|
||||
if returnURI := ctx.FormString("return_uri"); returnURI != "" && httplib.IsCurrentGiteaSiteURL(ctx, returnURI) {
|
||||
ctx.JSONRedirect(returnURI)
|
||||
return
|
||||
}
|
||||
|
||||
if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch {
|
||||
// Redirect to a pull request when possible
|
||||
redirectToPullRequest := false
|
||||
repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName
|
||||
if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest {
|
||||
redirectToPullRequest = true
|
||||
baseBranch = repo.BaseRepo.DefaultBranch
|
||||
headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch
|
||||
repo = repo.BaseRepo
|
||||
} else if repo.UnitEnabled(ctx, unit.TypePullRequests) {
|
||||
redirectToPullRequest = true
|
||||
}
|
||||
if redirectToPullRequest {
|
||||
ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// redirect to the newly updated file
|
||||
redirectTo := ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(parsed.NewBranchName) + "/" + util.PathEscapeSegments(treePath)
|
||||
redirectTo = strings.TrimSuffix(redirectTo, "/")
|
||||
ctx.JSONRedirect(redirectTo)
|
||||
}
|
||||
|
||||
func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) {
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
HandleGitError(ctx, "GetTreeEntryByPath", err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// No way to edit a directory online.
|
||||
if entry.IsDir() {
|
||||
ctx.NotFound(nil)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
blob := entry.Blob()
|
||||
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("getFileReader", err)
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if fInfo.isLFSFile() {
|
||||
lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
_ = dataRc.Close()
|
||||
ctx.ServerError("GetTreePathLock", err)
|
||||
return nil, nil, nil
|
||||
} else if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
|
||||
_ = dataRc.Close()
|
||||
ctx.NotFound(nil)
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
return buf, dataRc, fInfo
|
||||
}
|
||||
|
||||
func EditFile(ctx *context.Context) {
|
||||
editorAction := ctx.PathParam("editor_action")
|
||||
isNewFile := editorAction == "_new"
|
||||
ctx.Data["IsNewFile"] = isNewFile
|
||||
|
||||
// Check if the filename (and additional path) is specified in the querystring
|
||||
// (filename is a misnomer, but kept for compatibility with GitHub)
|
||||
urlQuery := ctx.Req.URL.Query()
|
||||
queryFilename := urlQuery.Get("filename")
|
||||
if queryFilename != "" {
|
||||
newTreePath := path.Join(ctx.Repo.TreePath, queryFilename)
|
||||
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(newTreePath))
|
||||
urlQuery.Del("filename")
|
||||
if newQueryParams := urlQuery.Encode(); newQueryParams != "" {
|
||||
redirectTo += "?" + newQueryParams
|
||||
}
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
// on the "New File" page, we should add an empty path field to make end users could input a new name
|
||||
prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath))
|
||||
|
||||
prepareEditorPage(ctx, editorAction)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if !isNewFile {
|
||||
prefetch, dataRc, fInfo := editFileOpenExisting(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
defer dataRc.Close()
|
||||
|
||||
ctx.Data["FileSize"] = fInfo.blobOrLfsSize
|
||||
|
||||
// Only some file types are editable online as text.
|
||||
if fInfo.isLFSFile() {
|
||||
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files")
|
||||
} else if !fInfo.st.IsRepresentableAsText() {
|
||||
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
|
||||
} else if fInfo.blobOrLfsSize >= setting.UI.MaxDisplayFileSize {
|
||||
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_too_large_file")
|
||||
}
|
||||
|
||||
if ctx.Data["NotEditableReason"] == nil {
|
||||
buf, err := io.ReadAll(io.MultiReader(bytes.NewReader(prefetch), dataRc))
|
||||
if err != nil {
|
||||
ctx.ServerError("ReadAll", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["FileContent"] = string(charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true, ErrorReturnOrigin: true}))
|
||||
}
|
||||
}
|
||||
|
||||
editorConfig := getCodeEditorConfigByEditorconfig(ctx, ctx.Repo.TreePath)
|
||||
editorConfig.Autofocus = !isNewFile
|
||||
if isNewFile {
|
||||
editorConfig.Filename = ""
|
||||
}
|
||||
ctx.Data["CodeEditorConfig"] = editorConfig
|
||||
ctx.HTML(http.StatusOK, tplEditFile)
|
||||
}
|
||||
|
||||
func EditFilePost(ctx *context.Context) {
|
||||
editorAction := ctx.PathParam("editor_action")
|
||||
isNewFile := editorAction == "_new"
|
||||
parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
defaultCommitMessage := util.Iif(isNewFile, ctx.Locale.TrString("repo.editor.add", parsed.form.TreePath), ctx.Locale.TrString("repo.editor.update", parsed.form.TreePath))
|
||||
|
||||
var operation string
|
||||
if isNewFile {
|
||||
operation = "create"
|
||||
} else if parsed.form.Content.Has() {
|
||||
// The form content only has data if the file is representable as text, is not too large and not in lfs.
|
||||
operation = "update"
|
||||
} else if ctx.Repo.TreePath != parsed.form.TreePath {
|
||||
// If it doesn't have data, the only possible operation is a "rename"
|
||||
operation = "rename"
|
||||
} else {
|
||||
// It should never happen, just in case
|
||||
ctx.JSONError(ctx.Tr("error.occurred"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
|
||||
LastCommitID: parsed.form.LastCommit,
|
||||
OldBranch: parsed.OldBranchName,
|
||||
NewBranch: parsed.NewBranchName,
|
||||
Message: parsed.GetCommitMessage(defaultCommitMessage),
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: operation,
|
||||
FromTreePath: ctx.Repo.TreePath,
|
||||
TreePath: parsed.form.TreePath,
|
||||
ContentReader: strings.NewReader(strings.ReplaceAll(parsed.form.Content.Value(), "\r", "")),
|
||||
},
|
||||
},
|
||||
Signoff: parsed.form.Signoff,
|
||||
Author: parsed.GitCommitter,
|
||||
Committer: parsed.GitCommitter,
|
||||
})
|
||||
if err != nil {
|
||||
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
|
||||
return
|
||||
}
|
||||
|
||||
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
|
||||
}
|
||||
|
||||
// DeleteFile render delete file page
|
||||
func DeleteFile(ctx *context.Context) {
|
||||
prepareEditorPage(ctx, "_delete")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.Data["PageIsDelete"] = true
|
||||
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
|
||||
ctx.HTML(http.StatusOK, tplDeleteFile)
|
||||
}
|
||||
|
||||
// DeleteFilePost response for deleting file or directory
|
||||
func DeleteFilePost(ctx *context.Context) {
|
||||
parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
treePath := ctx.Repo.TreePath
|
||||
if treePath == "" {
|
||||
ctx.JSONError("cannot delete root directory") // it should not happen unless someone is trying to be malicious
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the path is a directory
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetTreeEntryByPath", git.IsErrNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
var commitMessage string
|
||||
if entry.IsDir() {
|
||||
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete_directory", treePath))
|
||||
} else {
|
||||
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath))
|
||||
}
|
||||
|
||||
_, err = files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
|
||||
LastCommitID: parsed.form.LastCommit,
|
||||
OldBranch: parsed.OldBranchName,
|
||||
NewBranch: parsed.NewBranchName,
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "delete",
|
||||
TreePath: treePath,
|
||||
DeleteRecursively: true,
|
||||
},
|
||||
},
|
||||
Message: commitMessage,
|
||||
Signoff: parsed.form.Signoff,
|
||||
Author: parsed.GitCommitter,
|
||||
Committer: parsed.GitCommitter,
|
||||
})
|
||||
if err != nil {
|
||||
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
ctx.Flash.Success(ctx.Tr("repo.editor.directory_delete_success", treePath))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
|
||||
}
|
||||
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
|
||||
redirectForCommitChoice(ctx, parsed, redirectTreePath)
|
||||
}
|
||||
|
||||
func UploadFile(ctx *context.Context) {
|
||||
ctx.Data["PageIsUpload"] = true
|
||||
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
|
||||
opts := prepareEditorPage(ctx, "_upload")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
upload.AddUploadContextForRepo(ctx, opts.TargetRepo)
|
||||
|
||||
ctx.HTML(http.StatusOK, tplUploadFile)
|
||||
}
|
||||
|
||||
func UploadFilePost(ctx *context.Context) {
|
||||
parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/"))
|
||||
err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{
|
||||
LastCommitID: parsed.form.LastCommit,
|
||||
OldBranch: parsed.OldBranchName,
|
||||
NewBranch: parsed.NewBranchName,
|
||||
TreePath: parsed.form.TreePath,
|
||||
Message: parsed.GetCommitMessage(defaultCommitMessage),
|
||||
Files: parsed.form.Files,
|
||||
Signoff: parsed.form.Signoff,
|
||||
Author: parsed.GitCommitter,
|
||||
Committer: parsed.GitCommitter,
|
||||
})
|
||||
if err != nil {
|
||||
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
|
||||
return
|
||||
}
|
||||
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/repository/files"
|
||||
)
|
||||
|
||||
func NewDiffPatch(ctx *context.Context) {
|
||||
prepareEditorPage(ctx, "_diffpatch")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["PageIsPatch"] = true
|
||||
ctx.Data["CodeEditorConfig"] = CodeEditorConfig{Filename: "diff.patch"}
|
||||
ctx.HTML(http.StatusOK, tplPatchFile)
|
||||
}
|
||||
|
||||
// NewDiffPatchPost response for sending patch page
|
||||
func NewDiffPatchPost(ctx *context.Context) {
|
||||
parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch")
|
||||
_, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
|
||||
LastCommitID: parsed.form.LastCommit,
|
||||
OldBranch: parsed.OldBranchName,
|
||||
NewBranch: parsed.NewBranchName,
|
||||
Message: parsed.GetCommitMessage(defaultCommitMessage),
|
||||
Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"),
|
||||
Author: parsed.GitCommitter,
|
||||
Committer: parsed.GitCommitter,
|
||||
})
|
||||
if err != nil {
|
||||
err = util.ErrorWrapTranslatable(err, "repo.editor.fail_to_apply_patch")
|
||||
}
|
||||
if err != nil {
|
||||
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
|
||||
return
|
||||
}
|
||||
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/repository/files"
|
||||
)
|
||||
|
||||
func CherryPick(ctx *context.Context) {
|
||||
prepareEditorPage(ctx, "_cherrypick")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
fromCommitID := ctx.PathParam("sha")
|
||||
ctx.Data["FromCommitID"] = fromCommitID
|
||||
cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(fromCommitID)
|
||||
if err != nil {
|
||||
HandleGitError(ctx, "GetCommit", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.FormString("cherry-pick-type") == "revert" {
|
||||
ctx.Data["CherryPickType"] = "revert"
|
||||
ctx.Data["commit_summary"] = "revert " + ctx.PathParam("sha")
|
||||
ctx.Data["commit_message"] = "revert " + cherryPickCommit.MessageUTF8()
|
||||
} else {
|
||||
ctx.Data["CherryPickType"] = "cherry-pick"
|
||||
ctx.Data["commit_summary"], ctx.Data["commit_message"] = cherryPickCommit.MessageTitle(), cherryPickCommit.MessageBody()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplCherryPick)
|
||||
}
|
||||
|
||||
func CherryPickPost(ctx *context.Context) {
|
||||
fromCommitID := ctx.PathParam("sha")
|
||||
parsed := prepareEditorCommitSubmittedForm[*forms.CherryPickForm](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID))
|
||||
opts := &files.ApplyDiffPatchOptions{
|
||||
LastCommitID: parsed.form.LastCommit,
|
||||
OldBranch: parsed.OldBranchName,
|
||||
NewBranch: parsed.NewBranchName,
|
||||
Message: parsed.GetCommitMessage(defaultCommitMessage),
|
||||
Author: parsed.GitCommitter,
|
||||
Committer: parsed.GitCommitter,
|
||||
}
|
||||
|
||||
// First try the simple plain read-tree -m approach
|
||||
opts.Content = fromCommitID
|
||||
if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, parsed.form.Revert, opts); err != nil {
|
||||
// Drop through to the "apply" method
|
||||
buf := &bytes.Buffer{}
|
||||
if parsed.form.Revert {
|
||||
err = gitrepo.GetReverseRawDiff(ctx, ctx.Repo.Repository, fromCommitID, buf)
|
||||
} else {
|
||||
err = git.GetRawDiff(ctx.Repo.GitRepo, fromCommitID, git.RawDiffPatch, buf)
|
||||
}
|
||||
if err == nil {
|
||||
opts.Content = buf.String()
|
||||
_, err = files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts)
|
||||
if err != nil {
|
||||
err = util.ErrorWrapTranslatable(err, "repo.editor.fail_to_apply_patch")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2025 Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/utils"
|
||||
context_service "gitea.dev/services/context"
|
||||
files_service "gitea.dev/services/repository/files"
|
||||
)
|
||||
|
||||
func errorAs[T error](v error) (e T, ok bool) {
|
||||
if errors.As(v, &e) {
|
||||
return e, true
|
||||
}
|
||||
return e, false
|
||||
}
|
||||
|
||||
func editorHandleFileOperationErrorRender(ctx *context_service.Context, message, summary, details string) {
|
||||
flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
|
||||
"Message": message,
|
||||
"Summary": summary,
|
||||
"Details": utils.EscapeFlashErrorString(details),
|
||||
})
|
||||
if err == nil {
|
||||
ctx.JSONError(flashError)
|
||||
} else {
|
||||
log.Error("RenderToHTML(%q, %q, %q), error: %v", message, summary, details, err)
|
||||
ctx.JSONError("Unable to render error details, see server logs") // it should never happen
|
||||
}
|
||||
}
|
||||
|
||||
func editorHandleFileOperationError(ctx *context_service.Context, targetBranchName string, err error) {
|
||||
if errAs := util.ErrorAsTranslatable(err); errAs != nil {
|
||||
ctx.JSONError(errAs.Translate(ctx.Locale))
|
||||
} else if errAs, ok := errorAs[git.ErrNotExist](err); ok {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.file_modifying_no_longer_exists", errAs.RelPath))
|
||||
} else if errAs, ok := errorAs[git_model.ErrLFSFileLocked](err); ok {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.upload_file_is_locked", errAs.Path, errAs.UserName))
|
||||
} else if errAs, ok := errorAs[files_service.ErrFilenameInvalid](err); ok {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path))
|
||||
} else if errAs, ok := errorAs[files_service.ErrFilePathInvalid](err); ok {
|
||||
switch errAs.Type {
|
||||
case git.EntryModeSymlink:
|
||||
ctx.JSONError(ctx.Tr("repo.editor.file_is_a_symlink", errAs.Path))
|
||||
case git.EntryModeTree:
|
||||
ctx.JSONError(ctx.Tr("repo.editor.filename_is_a_directory", errAs.Path))
|
||||
case git.EntryModeBlob:
|
||||
ctx.JSONError(ctx.Tr("repo.editor.directory_is_a_file", errAs.Path))
|
||||
default:
|
||||
ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path))
|
||||
}
|
||||
} else if errAs, ok := errorAs[files_service.ErrRepoFileAlreadyExists](err); ok {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.file_already_exists", errAs.Path))
|
||||
} else if errAs, ok := errorAs[git.ErrBranchNotExist](err); ok {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.branch_does_not_exist", errAs.Name))
|
||||
} else if errAs, ok := errorAs[git_model.ErrBranchAlreadyExists](err); ok {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", errAs.BranchName))
|
||||
} else if files_service.IsErrCommitIDDoesNotMatch(err) {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.commit_id_not_matching"))
|
||||
} else if files_service.IsErrCommitIDDoesNotMatch(err) || git.IsErrPushOutOfDate(err) {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(ctx.Repo.CommitID)+"..."+util.PathEscapeSegments(targetBranchName)))
|
||||
} else if errAs, ok := errorAs[*git.ErrPushRejected](err); ok {
|
||||
if errAs.Message == "" {
|
||||
ctx.JSONError(ctx.Tr("repo.editor.push_rejected_no_message"))
|
||||
} else {
|
||||
editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.push_rejected"), ctx.Locale.TrString("repo.editor.push_rejected_summary"), errAs.Message)
|
||||
}
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.JSONError(ctx.Tr("error.not_found"))
|
||||
} else {
|
||||
setting.PanicInDevOrTesting("unclear err %T: %v", err, err)
|
||||
editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.failed_to_commit"), ctx.Locale.TrString("repo.editor.failed_to_commit_summary"), err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
|
||||
const tplEditorFork templates.TplName = "repo/editor/fork"
|
||||
|
||||
func ForkToEdit(ctx *context.Context) {
|
||||
ctx.HTML(http.StatusOK, tplEditorFork)
|
||||
}
|
||||
|
||||
func ForkToEditPost(ctx *context.Context) {
|
||||
ForkRepoTo(ctx, ctx.Doer, repo_service.ForkRepoOptions{
|
||||
BaseRepo: ctx.Repo.Repository,
|
||||
Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, ctx.Repo.Repository.Name),
|
||||
Description: ctx.Repo.Repository.Description,
|
||||
SingleBranch: ctx.Repo.Repository.DefaultBranch, // maybe we only need the default branch in the fork?
|
||||
})
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.JSONRedirect("") // reload the page, the new fork should be editable now
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
files_service "gitea.dev/services/repository/files"
|
||||
)
|
||||
|
||||
func DiffPreviewPost(ctx *context.Context) {
|
||||
newContent := ctx.FormString("content")
|
||||
treePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
|
||||
if treePath == "" {
|
||||
ctx.HTTPError(http.StatusBadRequest, "file name to diff is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetTreeEntryByPath", err)
|
||||
return
|
||||
} else if entry.IsDir() {
|
||||
ctx.HTTPError(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
oldContent, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBlobContent", err)
|
||||
return
|
||||
}
|
||||
diff, err := files_service.GetDiffPreview(ctx, ctx.Repo.Repository, ctx.Repo.BranchName, treePath, oldContent, newContent)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDiffPreview", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(diff.Files) != 0 {
|
||||
ctx.Data["File"] = diff.Files[0]
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplEditDiffPreview)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEditorUtils(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
t.Run("getUniquePatchBranchName", func(t *testing.T) {
|
||||
branchName := getUniquePatchBranchName(t.Context(), "user2", repo)
|
||||
assert.Equal(t, "user2-patch-1", branchName)
|
||||
})
|
||||
t.Run("getClosestParentWithFiles", func(t *testing.T) {
|
||||
gitRepo, _ := gitrepo.OpenRepository(t.Context(), repo)
|
||||
defer gitRepo.Close()
|
||||
treePath := getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "docs/foo/bar")
|
||||
assert.Equal(t, "docs", treePath)
|
||||
treePath = getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "any/other")
|
||||
assert.Empty(t, treePath)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/context/upload"
|
||||
files_service "gitea.dev/services/repository/files"
|
||||
)
|
||||
|
||||
// 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.ServerError("FormFile", 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.HTTPError(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
name := files_service.CleanGitTreePath(header.Filename)
|
||||
if len(name) == 0 {
|
||||
ctx.HTTPError(http.StatusBadRequest, "Upload file name is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: need to check the file size according to setting.Repository.Upload.FileMaxSize
|
||||
|
||||
uploaded, err := repo_model.NewUpload(ctx, name, buf, file)
|
||||
if err != nil {
|
||||
ctx.ServerError("NewUpload", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{"uuid": uploaded.UUID})
|
||||
}
|
||||
|
||||
// RemoveUploadFileFromServer remove file from server file dir
|
||||
func RemoveUploadFileFromServer(ctx *context.Context) {
|
||||
fileUUID := ctx.FormString("file")
|
||||
if err := repo_model.DeleteUploadByUUID(ctx, fileUUID); err != nil {
|
||||
ctx.ServerError("DeleteUploadByUUID", err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user