feat: Merge the main branch

This commit is contained in:
Pascal Zimmermann
2026-05-21 23:32:20 +02:00
916 changed files with 11563 additions and 7569 deletions
+17 -53
View File
@@ -1,65 +1,29 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"net/http"
"strings"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"io"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/services/context"
)
// IsArtifactV4 detects whether the artifact is likely from v4.
// V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash
// (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend.
func IsArtifactV4(art *actions_model.ActionArtifact) bool {
return strings.Contains(art.ContentEncodingOrType, "/")
}
type tagType string
func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) {
contentType := art.ContentEncodingOrType
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ContentType: contentType})
if err != nil {
return "", err
// BuildSignature builds a hmac signature for the input values.
// "tag" is an internal pre-defined static string to distinguish the signatures for different purpose.
func BuildSignature(tag tagType, vals ...string) []byte {
m := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret())
_, _ = io.WriteString(m, string(tag))
var buf8 [8]byte
for _, v := range vals {
binary.LittleEndian.PutUint64(buf8[:], uint64(len(v)))
_, _ = m.Write(buf8[:])
_, _ = io.WriteString(m, v)
}
return u.String(), nil
}
func DownloadArtifactV4ServeDirect(ctx *context.Base, art *actions_model.ActionArtifact) bool {
if !setting.Actions.ArtifactStorage.ServeDirect() {
return false
}
u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method)
if err != nil {
log.Error("GetArtifactV4ServeDirectURL: %v", err)
return false
}
ctx.Redirect(u, http.StatusFound)
return true
}
func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionArtifact) error {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return err
}
defer f.Close()
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{
Filename: art.ArtifactPath,
ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type
})
return nil
}
func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error {
if DownloadArtifactV4ServeDirect(ctx, art) {
return nil
}
return DownloadArtifactV4ReadStorage(ctx, art)
return m.Sum(nil)
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildSignature(t *testing.T) {
a := BuildSignature("v0", "x")
b := BuildSignature("v0", "x")
assert.Equal(t, a, b)
a = BuildSignature("v0", "x", "yz")
b = BuildSignature("v0", "xy", "z")
assert.NotEqual(t, a, b)
a = BuildSignature("v1", "x")
b = BuildSignature("v2", "x")
assert.NotEqual(t, a, b)
a = BuildSignature("v0", "x")
b = BuildSignature("v0x")
assert.NotEqual(t, a, b)
a = BuildSignature("v0", "", "x")
b = BuildSignature("v0", "x", "")
assert.NotEqual(t, a, b)
a = BuildSignature("v0")
b = BuildSignature("v0")
assert.Equal(t, a, b)
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"maps"
"slices"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log"
)
// CommitActionsStatusMap maps CommitStatus.ID to the live ActionRunJob status
// for Gitea Actions rows.
type CommitActionsStatusMap map[int64]actions_model.Status
// IconStatus returns the action status name to route the icon through
// repo/icons/action_status, or "" when the row isn't from Gitea Actions.
func (m CommitActionsStatusMap) IconStatus(s *git_model.CommitStatus) string {
if status, ok := m[s.ID]; ok {
return status.String()
}
return ""
}
// GetCommitActionsStatusMap resolves the live ActionRunJob.Status for every
// CommitStatus row backed by Gitea Actions. Rows from other sources (external
// CIs, API) are left untouched and rendered from their stored State.
func GetCommitActionsStatusMap(ctx context.Context, statuses []*git_model.CommitStatus) CommitActionsStatusMap {
if len(statuses) == 0 {
return nil
}
statusByJobID := make(map[int64]*git_model.CommitStatus)
repoByID := make(map[int64]*repo_model.Repository)
for _, status := range statuses {
if status == nil || status.TargetURL == "" {
continue
}
if status.Repo == nil {
status.Repo = repoByID[status.RepoID]
}
// ParseGiteaActionsTargetURL lazy-loads status.Repo on miss; cache the
// outcome so later entries with the same RepoID skip that load.
_, jobID, ok := status.ParseGiteaActionsTargetURL(ctx)
repoByID[status.RepoID] = status.Repo
if ok {
statusByJobID[jobID] = status
}
}
if len(statusByJobID) == 0 {
return nil
}
jobs := make(map[int64]*actions_model.ActionRunJob, len(statusByJobID))
if err := db.GetEngine(ctx).In("id", slices.Collect(maps.Keys(statusByJobID))).Cols("id", "status").Find(&jobs); err != nil {
log.Error("db.Find: failed to find action run jobs: %v", err)
return nil
}
info := make(CommitActionsStatusMap, len(jobs))
for jobID, status := range statusByJobID {
if job, ok := jobs[jobID]; ok {
info[status.ID] = job.Status
}
}
return info
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"regexp"
"strings"
"github.com/nektos/act/pkg/exprparser"
"gitea.com/gitea/runner/act/exprparser"
"go.yaml.in/yaml/v4"
)
+2 -2
View File
@@ -4,8 +4,8 @@
package jobparser
import (
"github.com/nektos/act/pkg/exprparser"
"github.com/nektos/act/pkg/model"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
+5 -2
View File
@@ -9,8 +9,8 @@ import (
"sort"
"strings"
"github.com/nektos/act/pkg/exprparser"
"github.com/nektos/act/pkg/model"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
@@ -48,6 +48,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
}
results := map[string]*JobResult{}
for id, job := range origin.Jobs {
if job == nil {
return nil, fmt.Errorf("needed job not found: %q", id)
}
outputs := pc.jobOutputs[id]
results[id] = &JobResult{
Needs: job.Needs(),
@@ -75,6 +75,13 @@ func TestParse(t *testing.T) {
wantErr: false,
},
}
invalidFileTests := []struct {
name string
}{
{name: "null_job_implicit"},
{name: "null_job_explicit"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
content := ReadTestdata(t, tt.name+".in.yaml")
@@ -100,6 +107,16 @@ func TestParse(t *testing.T) {
assert.Equal(t, string(want), builder.String())
})
}
for _, tt := range invalidFileTests {
t.Run(tt.name, func(t *testing.T) {
content := ReadTestdata(t, tt.name+".in.yaml")
require.NotPanics(t, func() {
_, err := Parse(content)
require.Error(t, err)
})
})
}
}
func TestDeepCopyYamlNode(t *testing.T) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"github.com/nektos/act/pkg/model"
"gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
"github.com/nektos/act/pkg/model"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
@@ -0,0 +1,9 @@
# null_job_explicit.in.yaml
on: push
jobs:
empty: null
notempty:
needs: empty
runs-on: ubuntu-latest
steps:
- run: echo ok
@@ -0,0 +1,9 @@
# null_job_implicit.in.yaml
on: push
jobs:
empty:
notempty:
needs: empty
runs-on: ubuntu-latest
steps:
- run: echo ok
@@ -0,0 +1,65 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflowpattern
import (
"strings"
"code.gitea.io/gitea/modules/glob"
)
type WorkflowPattern struct {
negative bool
glob glob.Glob
}
func CompilePatterns(patterns ...string) ([]*WorkflowPattern, error) {
ret := make([]*WorkflowPattern, 0, len(patterns))
for _, pattern := range patterns {
cp, err := glob.CompileWorkflow(pattern)
if err != nil {
return nil, err
}
ret = append(ret, &WorkflowPattern{glob: cp, negative: strings.HasPrefix(pattern, "!")})
}
return ret, nil
}
// Skip returns true if the workflow should be skipped per paths/branches semantics.
func Skip(sequence []*WorkflowPattern, input []string) bool {
allSkipped := true
for _, file := range input {
shouldSkip := true
for _, item := range sequence {
if item.negative {
// "!README.md" doesn't match "README.md", so "README.md" should be skipped
// "!README.md" matches "help.md" but it shouldn't affect "skip or not", because "help.md" might have been skipped by other rules like "docs/*.md"
if !item.glob.Match(file) {
shouldSkip = true
}
} else if item.glob.Match(file) {
// if "*.md" matches "help.md" so it shouldn't be skipped
shouldSkip = false
}
}
allSkipped = allSkipped && shouldSkip
}
return len(sequence) > 0 && allSkipped
}
// Filter returns true if the workflow should be skipped per paths-ignore/branches-ignore semantics.
func Filter(sequence []*WorkflowPattern, input []string) bool {
for _, file := range input {
anyMatched := false
for _, item := range sequence {
if anyMatched = item.glob.Match(file); anyMatched {
break
}
}
if !anyMatched {
return false
}
}
return len(sequence) != 0
}
@@ -0,0 +1,416 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflowpattern
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMatchPattern(t *testing.T) {
kases := []struct {
inputs []string
patterns []string
skipResult bool
filterResult bool
}{
{
patterns: []string{"*"},
inputs: []string{"path/with/slash"},
skipResult: true,
filterResult: false,
},
{
patterns: []string{"path/a", "path/b", "path/c"},
inputs: []string{"meta", "path/b", "otherfile"},
skipResult: false,
filterResult: false,
},
{
patterns: []string{"path/a", "path/b", "path/c"},
inputs: []string{"path/b"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"path/a", "path/b", "path/c"},
inputs: []string{"path/c", "path/b"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"path/a", "path/b", "path/c"},
inputs: []string{"path/c", "path/b", "path/a"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"path/a", "path/b", "path/c"},
inputs: []string{"path/c", "path/b", "path/d", "path/a"},
skipResult: false,
filterResult: false,
},
{
patterns: []string{},
inputs: []string{},
skipResult: false,
filterResult: false,
},
{
patterns: []string{"\\!file"},
inputs: []string{"!file"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"escape\\\\backslash"},
inputs: []string{"escape\\backslash"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{".yml"},
inputs: []string{"fyml"},
skipResult: true,
filterResult: false,
},
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#patterns-to-match-branches-and-tags
{
patterns: []string{"feature/*"},
inputs: []string{"feature/my-branch"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"feature/*"},
inputs: []string{"feature/your-branch"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"feature/**"},
inputs: []string{"feature/beta-a/my-branch"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"feature/**"},
inputs: []string{"feature/beta-a/my-branch"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"feature/**"},
inputs: []string{"feature/mona/the/octocat"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"main", "releases/mona-the-octocat"},
inputs: []string{"main"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"main", "releases/mona-the-octocat"},
inputs: []string{"releases/mona-the-octocat"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*"},
inputs: []string{"main"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*"},
inputs: []string{"releases"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**"},
inputs: []string{"all/the/branches"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**"},
inputs: []string{"every/tag"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*feature"},
inputs: []string{"mona-feature"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*feature"},
inputs: []string{"feature"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*feature"},
inputs: []string{"ver-10-feature"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"v2*"},
inputs: []string{"v2"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"v2*"},
inputs: []string{"v2.0"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"v2*"},
inputs: []string{"v2.9"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"v[12].[0-9]+.[0-9]+"},
inputs: []string{"v1.10.1"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"v[12].[0-9]+.[0-9]+"},
inputs: []string{"v2.0.0"},
skipResult: false,
filterResult: true,
},
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#patterns-to-match-file-paths
{
patterns: []string{"*"},
inputs: []string{"README.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*"},
inputs: []string{"server.rb"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.jsx?"},
inputs: []string{"page.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.jsx?"},
inputs: []string{"page.jsx"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**"},
inputs: []string{"all/the/files.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.js"},
inputs: []string{"app.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.js"},
inputs: []string{"index.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**.js"},
inputs: []string{"index.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**.js"},
inputs: []string{"js/index.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**.js"},
inputs: []string{"src/js/app.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"docs/*"},
inputs: []string{"docs/README.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"docs/*"},
inputs: []string{"docs/file.txt"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"docs/**"},
inputs: []string{"docs/README.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"docs/**"},
inputs: []string{"docs/mona/octocat.txt"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"docs/**/*.md"},
inputs: []string{"docs/README.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"docs/**/*.md"},
inputs: []string{"docs/mona/hello-world.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"docs/**/*.md"},
inputs: []string{"docs/a/markdown/file.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/docs/**"},
inputs: []string{"docs/hello.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/docs/**"},
inputs: []string{"dir/docs/my-file.txt"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/docs/**"},
inputs: []string{"space/docs/plan/space.doc"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/README.md"},
inputs: []string{"README.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/README.md"},
inputs: []string{"js/README.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/*src/**"},
inputs: []string{"a/src/app.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/*src/**"},
inputs: []string{"my-src/code/js/app.js"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/*-post.md"},
inputs: []string{"my-post.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/*-post.md"},
inputs: []string{"path/their-post.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/migrate-*.sql"},
inputs: []string{"migrate-10909.sql"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/migrate-*.sql"},
inputs: []string{"db/migrate-v1.0.sql"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"**/migrate-*.sql"},
inputs: []string{"db/sept/migrate-v1.sql"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.md", "!README.md"},
inputs: []string{"hello.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.md", "!README.md"},
inputs: []string{"README.md"},
skipResult: true,
filterResult: true,
},
{
patterns: []string{"*.md", "!README.md"},
inputs: []string{"docs/hello.md"},
skipResult: true,
filterResult: true,
},
{
patterns: []string{"*.md", "!README.md", "README*"},
inputs: []string{"hello.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.md", "!README.md", "README*"},
inputs: []string{"README.md"},
skipResult: false,
filterResult: true,
},
{
patterns: []string{"*.md", "!README.md", "README*"},
inputs: []string{"README.doc"},
skipResult: false,
filterResult: true,
},
}
for _, kase := range kases {
msg := fmt.Sprintf("patterns=%s, input=%s", strings.Join(kase.patterns, ","), strings.Join(kase.inputs, ","))
patterns, err := CompilePatterns(kase.patterns...)
assert.NoError(t, err, "compile error: %s", msg)
assert.Equal(t, kase.skipResult, Skip(patterns, kase.inputs), "unexpected skip result: %s", msg)
assert.Equal(t, kase.filterResult, Filter(patterns, kase.inputs), "unexpected filter result: %s", msg)
}
}
+15 -15
View File
@@ -9,6 +9,7 @@ import (
"strings"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/actions/workflowpattern"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/glob"
"code.gitea.io/gitea/modules/log"
@@ -17,8 +18,7 @@ import (
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
"github.com/nektos/act/pkg/model"
"github.com/nektos/act/pkg/workflowpattern"
"gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
@@ -297,7 +297,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Skip(patterns, []string{refName.BranchName()}) {
matchTimes++
}
case "branches-ignore":
@@ -309,7 +309,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Filter(patterns, []string{refName.BranchName()}) {
matchTimes++
}
case "tags":
@@ -321,7 +321,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Skip(patterns, []string{refName.TagName()}) {
matchTimes++
}
case "tags-ignore":
@@ -333,7 +333,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Filter(patterns, []string{refName.TagName()}) {
matchTimes++
}
case "paths":
@@ -349,7 +349,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
}
@@ -366,7 +366,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
}
@@ -492,7 +492,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Skip(patterns, []string{refName.ShortName()}) {
matchTimes++
}
case "branches-ignore":
@@ -501,7 +501,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Filter(patterns, []string{refName.ShortName()}) {
matchTimes++
}
case "paths":
@@ -513,7 +513,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
}
@@ -526,7 +526,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
}
@@ -747,7 +747,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{workflow.Name}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Skip(patterns, []string{workflow.Name}) {
matchTimes++
}
case "branches":
@@ -755,7 +755,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Skip(patterns, []string{payload.WorkflowRun.HeadBranch}) {
matchTimes++
}
case "branches-ignore":
@@ -763,7 +763,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) {
if !workflowpattern.Filter(patterns, []string{payload.WorkflowRun.HeadBranch}) {
matchTimes++
}
default:
+2 -2
View File
@@ -1,8 +1,8 @@
//go:build pam
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build pam
package pam
import (
+4 -1
View File
@@ -89,7 +89,10 @@ func ToUTF8(content []byte, opts ConvertOpts) []byte {
encoding, _ := charset.Lookup(charsetLabel)
if encoding == nil {
setting.PanicInDevOrTesting("unsupported detected charset %q, it shouldn't happen", charsetLabel)
return content
if opts.ErrorReturnOrigin {
return content
}
return bytes.ToValidUTF8(content, opts.ErrorReplacement)
}
var decoded []byte
+34 -16
View File
@@ -11,19 +11,28 @@ import (
"os/exec"
"strings"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/util"
)
type CommitMessage struct {
MessageRaw string
messageUTF8 *string
messageTitle *string
messageBody *string
}
// Commit represents a git commit.
type Commit struct {
Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache"
ID ObjectID
Author *Signature // never nil
Committer *Signature // never nil
CommitMessage string
Signature *CommitSignature
CommitMessage
ID ObjectID
Author *Signature // never nil
Committer *Signature // never nil
Signature *CommitSignature
Parents []ObjectID // ID strings
submoduleCache *ObjectCache[*SubModule]
@@ -35,19 +44,28 @@ type CommitSignature struct {
Payload string
}
// Message returns the commit message. Same as retrieving CommitMessage directly.
func (c *Commit) Message() string {
// FIXME: GIT-COMMIT-MESSAGE-ENCODING: this logic is not right
// * When need to use commit message in templates/database, it should be valid UTF-8
// * When need to get the original commit message, it should just use "c.CommitMessage"
// It's not easy to refactor at the moment, many templates need to be updated and tested
return c.CommitMessage
func (c *CommitMessage) MessageUTF8() string {
if c.messageUTF8 == nil {
bs := charset.ToUTF8(util.UnsafeStringToBytes(c.MessageRaw), charset.ConvertOpts{ErrorReplacement: []byte{'?'}})
c.messageUTF8 = new(util.UnsafeBytesToString(bs))
}
return *c.messageUTF8
}
// Summary returns first line of commit message.
// The string is forced to be valid UTF8
func (c *Commit) Summary() string {
return strings.ToValidUTF8(strings.Split(strings.TrimSpace(c.CommitMessage), "\n")[0], "?")
func (c *CommitMessage) MessageTitle() string {
if c.messageTitle == nil {
s, _, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
c.messageTitle = new(strings.TrimSpace(s))
}
return *c.messageTitle
}
func (c *CommitMessage) MessageBody() string {
if c.messageBody == nil {
_, s, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
c.messageBody = new(strings.TrimSpace(s))
}
return *c.messageBody
}
// ParentID returns oid of n-th parent (0-based index).
+1 -1
View File
@@ -66,7 +66,7 @@ func convertPGPSignature(c *object.Commit) *CommitSignature {
func convertCommit(c *object.Commit) *Commit {
return &Commit{
ID: ParseGogitHash(c.Hash),
CommitMessage: c.Message,
CommitMessage: CommitMessage{MessageRaw: c.Message},
Committer: &c.Committer,
Author: &c.Author,
Signature: convertPGPSignature(c),
+4 -6
View File
@@ -7,6 +7,7 @@ package git
import (
"context"
"maps"
"path"
"github.com/emirpasic/gods/trees/binaryheap"
@@ -47,9 +48,7 @@ func (tes Entries) GetCommitsInfo(ctx context.Context, repoLink string, commit *
return nil, nil, err
}
for k, v := range revs2 {
revs[k] = v
}
maps.Copy(revs, revs2)
}
} else {
revs, err = GetLastCommitForPaths(ctx, nil, c, treePath, entryPaths)
@@ -201,7 +200,7 @@ heaploop:
// Load the parent commits for the one we are currently examining
numParents := current.commit.NumParents()
var parents []cgobject.CommitNode
for i := 0; i < numParents; i++ {
for i := range numParents {
parent, err := current.commit.ParentNode(i)
if err != nil {
break
@@ -273,9 +272,8 @@ heaploop:
if len(newRemainingPaths) == 0 {
break
} else {
remainingPaths = newRemainingPaths
}
remainingPaths = newRemainingPaths
}
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ func CommitFromReader(gitRepo *Repository, objectID ObjectID, reader io.Reader)
}
}
commit.CommitMessage = messageSB.String()
commit.MessageRaw = messageSB.String()
if commit.Signature != nil {
commit.Signature.Payload = payloadSB.String()
}
+1 -1
View File
@@ -95,7 +95,7 @@ signed commit`, commitFromReader.Signature.Payload)
commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
assert.NoError(t, err)
commitFromReader.CommitMessage += "\n\n"
commitFromReader.CommitMessage.MessageRaw += "\n\n"
commitFromReader.Signature.Payload += "\n\n"
assert.Equal(t, commitFromReader, commitFromReader2)
}
+11 -2
View File
@@ -91,7 +91,7 @@ empty commit`, commitFromReader.Signature.Payload)
commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
assert.NoError(t, err)
commitFromReader.CommitMessage += "\n\n"
commitFromReader.CommitMessage.MessageRaw += "\n\n"
commitFromReader.Signature.Payload += "\n\n"
assert.Equal(t, commitFromReader, commitFromReader2)
}
@@ -154,11 +154,20 @@ ISO-8859-1`, commitFromReader.Signature.Payload)
commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
assert.NoError(t, err)
commitFromReader.CommitMessage += "\n\n"
commitFromReader.CommitMessage.MessageRaw += "\n\n"
commitFromReader.Signature.Payload += "\n\n"
assert.Equal(t, commitFromReader, commitFromReader2)
}
func TestCommitMessageSanitizesInvalidUTF8(t *testing.T) {
commit := &Commit{
CommitMessage: CommitMessage{MessageRaw: "title \xff\n\n\n\nbody \xff\n\n\n"},
}
assert.Equal(t, "title ÿ", commit.MessageTitle())
assert.Equal(t, "body ÿ", commit.MessageBody())
assert.Equal(t, "title ÿ\n\n\n\nbody ÿ\n\n\n", commit.MessageUTF8())
}
func TestHasPreviousCommit(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
-2
View File
@@ -111,8 +111,6 @@ func (p *Parser) parseRef(refBlock string) (map[string]string, error) {
len(fields), len(p.format.fieldNames))
}
for i, field := range fields {
field = strings.TrimSpace(field)
var fieldKey string
var fieldVal string
before, after, ok := strings.Cut(field, " ")
+2 -2
View File
@@ -116,12 +116,12 @@ func TestParser(t *testing.T) {
},
{
"refname:short": "v0.0.2",
"contents": "Update CI config (#651)",
"contents": "Update CI config (#651)\n\n",
"author": "John Doe <john.doe@foo.com> 1521643174 +0000",
},
{
"refname:short": "v0.0.3",
"contents": "Fixed code sample for bash completion (#687)",
"contents": "Fixed code sample for bash completion (#687)\n\n",
"author": "Foo Baz <foo@baz.com> 1524836750 +0200",
},
},
+8 -7
View File
@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"io"
"strings"
"code.gitea.io/gitea/modules/log"
@@ -30,7 +31,7 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note)
}
remainingCommitID := commitID
path := ""
var path strings.Builder
currentTree, err := notes.Tree.gogitTreeObject()
if err != nil {
return fmt.Errorf("unable to get tree object for notes commit %q: %w", notes.ID.String(), err)
@@ -41,17 +42,17 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note)
for len(remainingCommitID) > 2 {
file, err = currentTree.File(remainingCommitID)
if err == nil {
path += remainingCommitID
path.WriteString(remainingCommitID)
break
}
if err == object.ErrFileNotFound {
currentTree, err = currentTree.Tree(remainingCommitID[0:2])
path += remainingCommitID[0:2] + "/"
path.WriteString(remainingCommitID[0:2] + "/")
remainingCommitID = remainingCommitID[2:]
}
if err != nil {
if err == object.ErrDirectoryNotFound {
return ErrNotExist{ID: remainingCommitID, RelPath: path}
return ErrNotExist{ID: remainingCommitID, RelPath: path.String()}
}
log.Error("Unable to find git note corresponding to the commit %q. Error: %v", commitID, err)
return err
@@ -83,12 +84,12 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note)
return err
}
lastCommits, err := GetLastCommitForPaths(ctx, nil, commitNode, "", []string{path})
lastCommits, err := GetLastCommitForPaths(ctx, nil, commitNode, "", []string{path.String()})
if err != nil {
log.Error("Unable to get the commit for the path %q. Error: %v", path, err)
log.Error("Unable to get the commit for the path %q. Error: %v", path.String(), err)
return err
}
note.Commit = lastCommits[path]
note.Commit = lastCommits[path.String()]
return nil
}
+2
View File
@@ -43,6 +43,8 @@ func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
return entries, nil
}
var _ = catBatchParseTreeEntries // bypass "unused" lint because it is only used by "nogogit"
func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) {
entries := make([]*TreeEntry, 0, 10)
+1 -2
View File
@@ -10,7 +10,6 @@ import (
"bytes"
"io"
"sort"
"strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
@@ -97,7 +96,7 @@ func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader
result := LFSResult{
Name: curPath + fname,
SHA: curCommit.ID.String(),
Summary: strings.Split(strings.TrimSpace(curCommit.CommitMessage), "\n")[0],
Summary: curCommit.MessageTitle(),
When: curCommit.Author.When,
ParentHashes: curCommit.Parents,
}
+1 -1
View File
@@ -54,7 +54,7 @@ func TestGetTagCommitWithSignature(t *testing.T) {
assert.NotNil(t, commit)
assert.NotNil(t, commit.Signature)
// test that signature is not in message
assert.Equal(t, "signed-commit\n", commit.CommitMessage)
assert.Equal(t, "signed-commit\n", commit.CommitMessage.MessageRaw)
}
func TestGetCommitWithBadCommitID(t *testing.T) {
+5 -5
View File
@@ -17,21 +17,21 @@ import (
)
// CommitNodeIndex returns the index for walking commit graph
func (r *Repository) CommitNodeIndex() (cgobject.CommitNodeIndex, *os.File) {
indexPath := filepath.Join(r.Path, "objects", "info", "commit-graph")
func (repo *Repository) CommitNodeIndex() (cgobject.CommitNodeIndex, *os.File) {
indexPath := filepath.Join(repo.Path, "objects", "info", "commit-graph")
file, err := os.Open(indexPath)
if err == nil {
var index commitgraph.Index
index, err = commitgraph.OpenFileIndex(file)
if err == nil {
return cgobject.NewGraphCommitNodeIndex(index, r.gogitRepo.Storer), file
return cgobject.NewGraphCommitNodeIndex(index, repo.gogitRepo.Storer), file
}
}
if !os.IsNotExist(err) {
gitealog.Warn("Unable to read commit-graph for %s: %v", r.Path, err)
gitealog.Warn("Unable to read commit-graph for %s: %v", repo.Path, err)
}
return cgobject.NewObjectCommitNodeIndex(r.gogitRepo.Storer), nil
return cgobject.NewObjectCommitNodeIndex(repo.gogitRepo.Storer), nil
}
+3 -4
View File
@@ -176,15 +176,14 @@ func parseTagRef(ref map[string]string) (tag *Tag, err error) {
}
tag.Tagger = parseSignatureFromCommitLine(ref["creator"])
tag.Message = ref["contents"]
tag.MessageRaw = ref["contents"]
// strip any signature if present in contents field
_, tag.Message, _ = parsePayloadSignature(util.UnsafeStringToBytes(tag.Message), 0)
_, tag.MessageRaw, _ = parsePayloadSignature(util.UnsafeStringToBytes(tag.MessageRaw), 0)
// annotated tag with GPG signature
if tag.Type == "tag" && ref["contents:signature"] != "" {
payload := fmt.Sprintf("object %s\ntype commit\ntag %s\ntagger %s\n\n%s\n",
tag.Object, tag.Name, ref["creator"], strings.TrimSpace(tag.Message))
payload := fmt.Sprintf("object %s\ntype commit\ntag %s\ntagger %s\n\n%s", tag.Object, tag.Name, ref["creator"], tag.MessageRaw)
tag.Signature = &CommitSignature{
Signature: ref["contents:signature"],
Payload: payload,
+12 -12
View File
@@ -64,12 +64,12 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
return nil, err
}
tag := &Tag{
Name: name,
ID: tagID,
Object: commitID,
Type: tp,
Tagger: commit.Committer,
Message: commit.Message(),
Name: name,
ID: tagID,
Object: commitID,
Type: tp,
Tagger: commit.Committer,
CommitMessage: CommitMessage{MessageRaw: commit.CommitMessage.MessageRaw},
}
repo.tagCache.Set(tagID.String(), tag)
@@ -86,12 +86,12 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
}
tag := &Tag{
Name: name,
ID: tagID,
Object: commitID.Type().MustID(gogitTag.Target[:]),
Type: tp,
Tagger: &gogitTag.Tagger,
Message: gogitTag.Message,
Name: name,
ID: tagID,
Object: commitID.Type().MustID(gogitTag.Target[:]),
Type: tp,
Tagger: &gogitTag.Tagger,
CommitMessage: CommitMessage{MessageRaw: gogitTag.Message},
}
repo.tagCache.Set(tagID.String(), tag)
+6 -6
View File
@@ -71,12 +71,12 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
return nil, err
}
tag := &Tag{
Name: name,
ID: tagID,
Object: commitID,
Type: tp,
Tagger: commit.Committer,
Message: commit.Message(),
Name: name,
ID: tagID,
Object: commitID,
Type: tp,
Tagger: commit.Committer,
CommitMessage: commit.CommitMessage,
}
repo.tagCache.Set(tagID.String(), tag)
+21 -20
View File
@@ -211,13 +211,13 @@ func TestRepository_parseTagRef(t *testing.T) {
},
want: &Tag{
Name: "v1.9.1",
ID: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
Object: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
Type: "commit",
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
Signature: nil,
Name: "v1.9.1",
ID: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
Object: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
Type: "commit",
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
CommitMessage: CommitMessage{MessageRaw: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n"},
Signature: nil,
},
},
@@ -240,13 +240,13 @@ func TestRepository_parseTagRef(t *testing.T) {
},
want: &Tag{
Name: "v0.0.1",
ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
Type: "tag",
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
Signature: nil,
Name: "v0.0.1",
ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
Type: "tag",
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
CommitMessage: CommitMessage{MessageRaw: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n"},
Signature: nil,
},
},
@@ -263,6 +263,7 @@ func TestRepository_parseTagRef(t *testing.T) {
* add changelog of v1.9.1
* Update CHANGELOG.md
-----BEGIN PGP SIGNATURE-----
aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3
@@ -298,12 +299,12 @@ qbHDASXl
},
want: &Tag{
Name: "v0.0.1",
ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
Type: "tag",
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md",
Name: "v0.0.1",
ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
Type: "tag",
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
CommitMessage: CommitMessage{MessageRaw: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n"},
Signature: &CommitSignature{
Signature: `-----BEGIN PGP SIGNATURE-----
+3 -2
View File
@@ -12,12 +12,13 @@ import (
// Tag represents a Git tag.
type Tag struct {
CommitMessage
Name string
ID ObjectID
Object ObjectID // The id of this commit object
Type string
Tagger *Signature
Message string
Signature *CommitSignature
}
@@ -87,7 +88,7 @@ func parseTagData(objectFormat ObjectFormat, data []byte) (*Tag, error) {
pos += eol + 1
}
payload, msg, sign := parsePayloadSignature(data, pos)
tag.Message = msg
tag.MessageRaw = msg
if len(sign) > 0 {
tag.Signature = &CommitSignature{Signature: sign, Payload: payload}
}
+14 -15
View File
@@ -28,7 +28,6 @@ tagger Lucas Michot <lucas@semalead.com> 1484491741 +0100
Object: MustIDFromString("3b114ab800c6432ad42387ccf6bc8d4388a2885a"),
Type: "commit",
Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484491741, 0).In(time.FixedZone("", 3600))},
Message: "",
Signature: nil,
},
},
@@ -43,13 +42,13 @@ o
ono`,
expected: Tag{
Name: "",
ID: Sha1ObjectFormat.EmptyObjectID(),
Object: MustIDFromString("7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc"),
Type: "commit",
Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484553735, 0).In(time.FixedZone("", 3600))},
Message: "test message\no\n\nono",
Signature: nil,
Name: "",
ID: Sha1ObjectFormat.EmptyObjectID(),
Object: MustIDFromString("7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc"),
Type: "commit",
Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484553735, 0).In(time.FixedZone("", 3600))},
CommitMessage: CommitMessage{MessageRaw: "test message\no\n\nono"},
Signature: nil,
},
},
{
@@ -64,12 +63,12 @@ dummy signature
-----END SSH SIGNATURE-----
`,
expected: Tag{
Name: "",
ID: Sha1ObjectFormat.EmptyObjectID(),
Object: MustIDFromString("7cdf42c0b1cc763ab7e4c33c47a24e27c66bfaaa"),
Type: "commit",
Tagger: &Signature{Name: "dummy user", Email: "dummy-email@example.com", When: time.Unix(1484491741, 0).In(time.FixedZone("", 3600))},
Message: "dummy message",
Name: "",
ID: Sha1ObjectFormat.EmptyObjectID(),
Object: MustIDFromString("7cdf42c0b1cc763ab7e4c33c47a24e27c66bfaaa"),
Type: "commit",
Tagger: &Signature{Name: "dummy user", Email: "dummy-email@example.com", When: time.Unix(1484491741, 0).In(time.FixedZone("", 3600))},
CommitMessage: CommitMessage{MessageRaw: "dummy message"},
Signature: &CommitSignature{
Signature: `-----BEGIN SSH SIGNATURE-----
dummy signature
@@ -93,5 +92,5 @@ dummy message`,
tag, err := parseTagData(Sha1ObjectFormat, []byte("type commit\n\nfoo\n-----BEGIN SSH SIGNATURE-----\ncorrupted..."))
assert.NoError(t, err)
assert.Equal(t, "foo\n-----BEGIN SSH SIGNATURE-----\ncorrupted...", tag.Message)
assert.Equal(t, "foo\n-----BEGIN SSH SIGNATURE-----\ncorrupted...", tag.CommitMessage.MessageRaw)
}
+2 -2
View File
@@ -21,7 +21,7 @@ func TestEntryGogit(t *testing.T) {
EntryModeTree: filemode.Dir,
}
for emode, fmode := range cases {
assert.EqualValues(t, fmode, entryModeToGogitFileMode(emode))
assert.EqualValues(t, emode, gogitFileModeToEntryMode(fmode))
assert.Equal(t, fmode, entryModeToGogitFileMode(emode))
assert.Equal(t, emode, gogitFileModeToEntryMode(fmode))
}
}
+99 -40
View File
@@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
"code.gitea.io/gitea/modules/util"
)
@@ -18,19 +20,27 @@ type Glob interface {
}
type globCompiler struct {
regexpQuestion bool
regexpPlus bool
superWildcardRight bool
supportNegative bool
separators []rune
nonSeparatorChars string
globPattern []rune
regexpPattern string
regexp *regexp.Regexp
builder *strings.Builder
pos int
negativeFlip bool
}
// compileChars compiles character class patterns like [abc] or [!abc]
func (g *globCompiler) compileChars() (string, error) {
result := ""
func (g *globCompiler) compileChars() error {
g.builder.WriteByte('[')
if g.pos < len(g.globPattern) && g.globPattern[g.pos] == '!' {
g.pos++
result += "^"
g.builder.WriteByte('^')
}
for g.pos < len(g.globPattern) {
@@ -38,81 +48,111 @@ func (g *globCompiler) compileChars() (string, error) {
g.pos++
if c == ']' {
return "[" + result + "]", nil
g.builder.WriteByte(']')
return nil
}
if c == '\\' {
if g.pos >= len(g.globPattern) {
return "", errors.New("unterminated character class escape")
return errors.New("unterminated character class escape")
}
result += "\\" + string(g.globPattern[g.pos])
g.builder.WriteByte('\\')
g.builder.WriteRune(g.globPattern[g.pos])
g.pos++
} else {
result += string(c)
g.builder.WriteRune(c)
}
}
return "", errors.New("unterminated character class")
return errors.New("unterminated character class")
}
// compile compiles the glob pattern into a regular expression
func (g *globCompiler) compile(subPattern bool) (string, error) {
result := ""
func (g *globCompiler) compile(subPattern bool) error {
for g.pos < len(g.globPattern) {
c := g.globPattern[g.pos]
g.pos++
if subPattern && c == '}' {
return "(" + result + ")", nil
g.builder.WriteByte(')')
return nil
}
switch c {
case '*':
if g.pos < len(g.globPattern) && g.globPattern[g.pos] == '*' {
g.pos++
result += ".*" // match any sequence of characters
var matchRightSep bool
if g.superWildcardRight {
// check "**/" pattern, then the wildcards should also match the right separator
// e.g.: "**/docs" should match "docs"
var rightRune rune
if g.pos+1 < len(g.globPattern) {
rightRune = g.globPattern[g.pos+1]
}
if slices.Contains(g.separators, rightRune) {
matchRightSep = g.pos-2 < 0 || g.globPattern[g.pos-2] == rightRune
}
}
if matchRightSep {
g.pos += 2
} else {
g.pos++
}
g.builder.WriteString(".*") // match any sequence of characters
} else {
result += g.nonSeparatorChars + "*" // match any sequence of non-separator characters
g.builder.WriteString(g.nonSeparatorChars)
g.builder.WriteByte('*') // match any sequence of non-separator characters
}
case '?':
result += g.nonSeparatorChars // match any single non-separator character
if g.regexpQuestion {
g.builder.WriteByte('?')
} else {
g.builder.WriteString(g.nonSeparatorChars) // match any single non-separator character
}
case '+':
if g.regexpPlus {
g.builder.WriteByte('+')
} else {
g.builder.WriteByte('\\')
g.builder.WriteRune(c)
}
case '[':
chars, err := g.compileChars()
if err != nil {
return "", err
if err := g.compileChars(); err != nil {
return err
}
result += chars
case '{':
subResult, err := g.compile(true)
if err != nil {
return "", err
g.builder.WriteByte('(')
if err := g.compile(true); err != nil {
return err
}
result += subResult
case ',':
if subPattern {
result += "|"
g.builder.WriteByte('|')
} else {
result += ","
g.builder.WriteByte(',')
}
case '\\':
if g.pos >= len(g.globPattern) {
return "", errors.New("no character to escape")
return errors.New("no character to escape")
}
result += "\\" + string(g.globPattern[g.pos])
g.builder.WriteByte('\\')
g.builder.WriteRune(g.globPattern[g.pos])
g.pos++
case '.', '+', '^', '$', '(', ')', '|':
result += "\\" + string(c) // escape regexp special characters
case '.', '^', '$', '(', ')', '|':
g.builder.WriteByte('\\')
g.builder.WriteRune(c) // escape regexp special characters
default:
result += string(c)
g.builder.WriteRune(c)
}
}
return result, nil
return nil
}
func newGlobCompiler(pattern string, separators ...rune) (Glob, error) {
g := &globCompiler{globPattern: []rune(pattern)}
func initGlobCompiler(g *globCompiler, pattern string, separators []rune) (Glob, error) {
g.globPattern = []rune(pattern)
g.separators = separators
g.builder = new(strings.Builder)
// Escape separators for use in character class
escapedSeparators := regexp.QuoteMeta(string(separators))
@@ -122,12 +162,18 @@ func newGlobCompiler(pattern string, separators ...rune) (Glob, error) {
g.nonSeparatorChars = "."
}
compiled, err := g.compile(false)
if err != nil {
return nil, err
if g.supportNegative && len(g.globPattern) > 0 && g.globPattern[0] == '!' {
g.negativeFlip = true
g.pos++
}
g.regexpPattern = "^" + compiled + "$"
g.builder.WriteByte('^')
if err := g.compile(false); err != nil {
return nil, err
}
g.builder.WriteByte('$')
g.regexpPattern = g.builder.String()
g.builder = nil
regex, err := regexp.Compile(g.regexpPattern)
if err != nil {
@@ -139,11 +185,24 @@ func newGlobCompiler(pattern string, separators ...rune) (Glob, error) {
}
func (g *globCompiler) Match(s string) bool {
return g.regexp.MatchString(s)
ret := g.regexp.MatchString(s)
if g.negativeFlip {
ret = !ret
}
return ret
}
func Compile(pattern string, separators ...rune) (Glob, error) {
return newGlobCompiler(pattern, separators...)
return initGlobCompiler(&globCompiler{}, pattern, separators)
}
func CompileWorkflow(pattern string) (Glob, error) {
return initGlobCompiler(&globCompiler{
regexpQuestion: true,
regexpPlus: true,
superWildcardRight: true,
supportNegative: true,
}, pattern, []rune{'/'})
}
func MustCompile(pattern string, separators ...rune) Glob {
+24 -16
View File
@@ -6,31 +6,39 @@ package globallock
import (
"context"
"sync"
"sync/atomic"
"code.gitea.io/gitea/modules/setting"
)
var (
defaultLocker Locker
initOnce sync.Once
initFunc = func() {
switch setting.GlobalLock.ServiceType {
case "redis":
defaultLocker = NewRedisLocker(setting.GlobalLock.ServiceConnStr)
case "memory":
fallthrough
default:
defaultLocker = NewMemoryLocker()
}
} // define initFunc as a variable to make it possible to change it in tests
defaultLocker atomic.Pointer[Locker]
defaultMutex sync.Mutex
)
func initDefaultLocker() Locker {
switch setting.GlobalLock.ServiceType {
case "redis":
return NewRedisLocker(setting.GlobalLock.ServiceConnStr)
default: // "memory"
return NewMemoryLocker()
}
}
// DefaultLocker returns the default locker.
func DefaultLocker() Locker {
initOnce.Do(func() {
initFunc()
})
return defaultLocker
ptr := defaultLocker.Load()
if ptr == nil {
defaultMutex.Lock()
ptr = defaultLocker.Load()
if ptr == nil {
ptr = new(initDefaultLocker())
defaultLocker.Store(ptr)
}
defaultMutex.Unlock()
ptr = defaultLocker.Load()
}
return *ptr
}
// Lock tries to acquire a lock for the given key, it uses the default locker.
+7 -50
View File
@@ -5,7 +5,6 @@ package globallock
import (
"context"
"os"
"sync"
"testing"
@@ -15,50 +14,13 @@ import (
func TestLockAndDo(t *testing.T) {
t.Run("redis", func(t *testing.T) {
url := "redis://127.0.0.1:6379/0"
if os.Getenv("CI") == "" {
// Make it possible to run tests against a local redis instance
url = os.Getenv("TEST_REDIS_URL")
if url == "" {
t.Skip("TEST_REDIS_URL not set and not running in CI")
return
}
}
oldDefaultLocker := defaultLocker
oldInitFunc := initFunc
defer func() {
defaultLocker = oldDefaultLocker
initFunc = oldInitFunc
if defaultLocker == nil {
initOnce = sync.Once{}
}
}()
initOnce = sync.Once{}
initFunc = func() {
defaultLocker = NewRedisLocker(url)
}
locker := newTestRedisLocker(t)
defaultLocker.Store(new(locker))
testLockAndDo(t)
require.NoError(t, defaultLocker.(*redisLocker).Close())
require.NoError(t, locker.(*redisLocker).Close())
})
t.Run("memory", func(t *testing.T) {
oldDefaultLocker := defaultLocker
oldInitFunc := initFunc
defer func() {
defaultLocker = oldDefaultLocker
initFunc = oldInitFunc
if defaultLocker == nil {
initOnce = sync.Once{}
}
}()
initOnce = sync.Once{}
initFunc = func() {
defaultLocker = NewMemoryLocker()
}
defaultLocker.Store(new(NewMemoryLocker()))
testLockAndDo(t)
})
}
@@ -69,13 +31,10 @@ func testLockAndDo(t *testing.T) {
ctx := t.Context()
count := 0
wg := sync.WaitGroup{}
wg.Add(concurrency)
for range concurrency {
go func() {
defer wg.Done()
wg.Go(func() {
err := LockAndDo(ctx, "test", func(ctx context.Context) error {
count++
// It's impossible to acquire the lock inner the function
ok, err := TryLockAndDo(ctx, "test", func(ctx context.Context) error {
assert.Fail(t, "should not acquire the lock")
@@ -83,13 +42,11 @@ func testLockAndDo(t *testing.T) {
})
assert.False(t, ok)
assert.NoError(t, err)
return nil
})
require.NoError(t, err)
}()
assert.NoError(t, err)
})
}
wg.Wait()
assert.Equal(t, concurrency, count)
+17 -16
View File
@@ -10,29 +10,30 @@ import (
"testing"
"time"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"github.com/go-redsync/redsync/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestRedisLocker(t *testing.T) Locker {
t.Helper()
redisURL := util.IfZero(os.Getenv("TEST_REDIS_URL"), "redis://127.0.0.1:6379/0")
rl := NewRedisLocker(redisURL).(*redisLocker)
err := rl.conn.Ping(t.Context()).Err()
if err != nil && test.AllowSkipExternalService() {
t.Skip("no redis server for testing, skipped")
}
require.NoError(t, err, "redis error for testing: %v", err)
return rl
}
func TestLocker(t *testing.T) {
t.Run("redis", func(t *testing.T) {
url := "redis://127.0.0.1:6379/0"
if os.Getenv("CI") == "" {
// Make it possible to run tests against a local redis instance
url = os.Getenv("TEST_REDIS_URL")
if url == "" {
t.Skip("TEST_REDIS_URL not set and not running in CI")
return
}
}
oldExpiry := redisLockExpiry
redisLockExpiry = 5 * time.Second // make it shorter for testing
defer func() {
redisLockExpiry = oldExpiry
}()
locker := NewRedisLocker(url)
defer test.MockVariableValue(&redisLockExpiry, 5*time.Second)() // make it shorter for testing
locker := newTestRedisLocker(t)
testLocker(t, locker)
testRedisLocker(t, locker.(*redisLocker))
require.NoError(t, locker.(*redisLocker).Close())
+6 -8
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-redsync/redsync/v4"
"github.com/go-redsync/redsync/v4/redis/goredis/v9"
"github.com/redis/go-redis/v9"
)
const redisLockKeyPrefix = "gitea:globallock:"
@@ -23,7 +24,8 @@ const redisLockKeyPrefix = "gitea:globallock:"
var redisLockExpiry = 30 * time.Second
type redisLocker struct {
rs *redsync.Redsync
conn redis.UniversalClient
rs *redsync.Redsync
mutexM sync.Map
closed atomic.Bool
@@ -33,17 +35,13 @@ type redisLocker struct {
var _ Locker = &redisLocker{}
func NewRedisLocker(connection string) Locker {
conn := nosql.GetManager().GetRedisClient(connection)
l := &redisLocker{
rs: redsync.New(
goredis.NewPool(
nosql.GetManager().GetRedisClient(connection),
),
),
conn: conn,
rs: redsync.New(goredis.NewPool(conn)),
}
l.extendWg.Add(1)
l.startExtend()
return l
}
+2 -2
View File
@@ -98,7 +98,7 @@ func (g *Manager) RunAtTerminate(terminate func()) {
defer g.terminateWaitGroup.Done()
defer func() {
if err := recover(); err != nil {
log.Critical("PANIC during RunAtTerminate: %v\nStacktrace: %s", err, log.Stack(2))
log.Error("PANIC during RunAtTerminate: %v\nStacktrace: %s", err, log.Stack(2))
}
}()
terminate()
@@ -113,7 +113,7 @@ func (g *Manager) RunAtShutdown(ctx context.Context, shutdown func()) {
func() {
defer func() {
if err := recover(); err != nil {
log.Critical("PANIC during RunAtShutdown: %v\nStacktrace: %s", err, log.Stack(2))
log.Error("PANIC during RunAtShutdown: %v\nStacktrace: %s", err, log.Stack(2))
}
}()
select {
+31
View File
@@ -83,3 +83,34 @@ func HTMLPrintTag(w io.Writer, tag template.HTML, attrs map[string]string) (writ
written += n
return written, err
}
func EscapeString(s string) template.HTML {
return template.HTML(template.HTMLEscapeString(s))
}
type HTMLBuilder struct {
sb strings.Builder
}
func (b *HTMLBuilder) WriteString(s string) *HTMLBuilder {
b.sb.WriteString(template.HTMLEscapeString(s))
return b
}
func (b *HTMLBuilder) WriteHTML(s template.HTML) *HTMLBuilder {
b.sb.WriteString(string(s))
return b
}
func (b *HTMLBuilder) WriteFormat(fmt template.HTML, args ...any) *HTMLBuilder {
_, _ = HTMLPrintf(&b.sb, fmt, args...)
return b
}
func (b *HTMLBuilder) HTMLString() template.HTML {
return template.HTML(b.sb.String())
}
func (b *HTMLBuilder) String() string {
return b.sb.String()
}
+7
View File
@@ -22,3 +22,10 @@ func TestHTMLFormat(t *testing.T) {
assert.Equal(t, template.HTML("&lt;&gt;"), HTMLFormat("%s", template.URL("<>")))
assert.Equal(t, template.HTML("&amp;StringMethod &amp;StringMethod"), HTMLFormat("%s %s", testStringer{}, &testStringer{}))
}
func TestHTMLBuilder(t *testing.T) {
b := &HTMLBuilder{}
b.WriteString("<").WriteHTML("<hr>").WriteFormat("<span>%s%s</span>", ">", EscapeString(">"))
assert.Equal(t, "&lt;<hr><span>&gt;&gt;</span>", b.String())
assert.Equal(t, template.HTML("&lt;<hr><span>&gt;&gt;</span>"), b.HTMLString())
}
+3 -2
View File
@@ -87,8 +87,9 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
if opts.ContentLength != nil {
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
}
if opts.Filename != "" && opts.ContentDisposition != "" {
header.Set("Content-Disposition", encodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename)))
if opts.Filename != "" {
contentDisposition := util.IfZero(opts.ContentDisposition, ContentDispositionAttachment)
header.Set("Content-Disposition", encodeContentDisposition(contentDisposition, path.Base(opts.Filename)))
header.Set("Access-Control-Expose-Headers", "Content-Disposition")
}
+8
View File
@@ -133,3 +133,11 @@ func TestServeSetHeaderContentRelated(t *testing.T) {
// make sure sandboxed
require.Contains(t, serveHeaderCspDefault, "; sandbox")
}
func TestServeSetHeaders(t *testing.T) {
w := httptest.NewRecorder()
ServeSetHeaders(w, ServeHeaderOptions{Filename: "foo.zip"})
assert.Equal(t, "attachment; filename=foo.zip", w.Header().Get("Content-Disposition"))
ServeSetHeaders(w, ServeHeaderOptions{Filename: "foo.zip", ContentDisposition: ContentDispositionInline})
assert.Equal(t, "inline; filename=foo.zip", w.Header().Get("Content-Disposition"))
}
@@ -4,24 +4,19 @@
package elasticsearch
import (
"os"
"strings"
"testing"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/require"
)
func newRealIndexer(t *testing.T) *Indexer {
t.Helper()
url := "http://elasticsearch:9200"
if os.Getenv("CI") == "" {
url = os.Getenv("TEST_ELASTICSEARCH_URL")
if url == "" {
t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI")
}
}
esURL := test.ExternalServiceHTTP(t, "TEST_ELASTICSEARCH_URL", "http://elasticsearch:9200")
indexName := "gitea_test_" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "_")
ix := NewIndexer(url, indexName, 1, `{"mappings":{"properties":{"x":{"type":"keyword"}}}}`)
ix := NewIndexer(esURL, indexName, 1, `{"mappings":{"properties":{"x":{"type":"keyword"}}}}`)
_, err := ix.Init(t.Context())
require.NoError(t, err)
t.Cleanup(ix.Close)
@@ -7,27 +7,18 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"testing"
"time"
"code.gitea.io/gitea/modules/indexer/issues/internal/tests"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/require"
)
func TestElasticsearchIndexer(t *testing.T) {
// The elasticsearch instance started by pull-db-tests.yml > test-unit > services > elasticsearch
rawURL := "http://elastic:changeme@elasticsearch:9200"
if os.Getenv("CI") == "" {
// Make it possible to run tests against a local elasticsearch instance
rawURL = os.Getenv("TEST_ELASTICSEARCH_URL")
if rawURL == "" {
t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI")
return
}
}
rawURL := test.ExternalServiceHTTP(t, "TEST_ELASTICSEARCH_URL", "http://elastic:changeme@elasticsearch:9200")
// Go's net/http does not auto-attach URL userinfo as Basic Auth, so extract
// it and set the header explicitly; otherwise auth-enforced clusters answer
@@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/indexer/issues/internal/tests"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/test"
"github.com/meilisearch/meilisearch-go"
"github.com/stretchr/testify/assert"
@@ -21,18 +22,8 @@ import (
func TestMeilisearchIndexer(t *testing.T) {
// The meilisearch instance started by pull-db-tests.yml > test-unit > services > meilisearch
url := "http://meilisearch:7700"
key := "" // auth has been disabled in test environment
if os.Getenv("CI") == "" {
// Make it possible to run tests against a local meilisearch instance
url = os.Getenv("TEST_MEILISEARCH_URL")
if url == "" {
t.Skip("TEST_MEILISEARCH_URL not set and not running in CI")
return
}
key = os.Getenv("TEST_MEILISEARCH_KEY")
}
url := test.ExternalServiceHTTP(t, "TEST_MEILISEARCH_URL", "http://meilisearch:7700")
key := os.Getenv("TEST_MEILISEARCH_KEY")
require.Eventually(t, func() bool {
resp, err := http.Get(url)
-4
View File
@@ -54,10 +54,6 @@ func ErrorWithSkip(skip int, format string, v ...any) {
Log(skip+1, ERROR, format, v...)
}
func Critical(format string, v ...any) {
Log(1, ERROR, format, v...)
}
var OsExiter = os.Exit
// Fatal records fatal log and exit process
+21
View File
@@ -5,6 +5,7 @@ package log
import (
"context"
"net/url"
"reflect"
"runtime"
"strings"
@@ -226,6 +227,8 @@ func (l *LoggerImpl) Log(skip int, event *Event, format string, logArgs ...any)
}
} else if ls := asLogStringer(v); ls != nil {
msgArgs[i] = logStringFormatter{v: ls}
} else if str, ok := v.(string); ok {
msgArgs[i] = protectSensitiveInfo(str)
}
}
@@ -235,6 +238,24 @@ func (l *LoggerImpl) Log(skip int, event *Event, format string, logArgs ...any)
l.SendLogEvent(event)
}
func protectSensitiveInfo(s string) string {
u, err := url.Parse(s)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return s
}
q := u.Query()
for _, vals := range q {
for i := range vals {
vals[i] = "_"
}
}
masked := &url.URL{Scheme: u.Scheme, Host: u.Host, Path: u.Path, RawQuery: q.Encode()}
if u.User != nil {
masked.User = url.User("_masked_")
}
return masked.String()
}
func (l *LoggerImpl) GetLevel() Level {
return Level(l.level.Load())
}
+7
View File
@@ -177,3 +177,10 @@ func TestLoggerExpressionFilter(t *testing.T) {
assert.Equal(t, []string{"foo\n", "foo bar\n", "by filename\n"}, w1.FetchLogs())
}
func TestProtectSensitiveInfo(t *testing.T) {
assert.Empty(t, protectSensitiveInfo(""))
assert.Equal(t, "mailto:user@example.com", protectSensitiveInfo("mailto:user@example.com"))
assert.Equal(t, "https://example.com", protectSensitiveInfo("https://example.com"))
assert.Equal(t, "https://_masked_@example.com/path?k=_", protectSensitiveInfo("https://u:p@example.com/path?k=v#hash"))
}
+1 -1
View File
@@ -22,7 +22,7 @@ func TestRenderCodePreview(t *testing.T) {
},
})
test := func(input, expected string) {
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithMarkupType(markdown.MarkupName), input)
buffer, err := testRenderString(markup.NewTestRenderContext().WithMarkupType(markdown.MarkupName), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
+11 -5
View File
@@ -24,10 +24,16 @@ var (
localMetas = map[string]string{"user": testRepoOwnerName, "repo": testRepoName}
)
func testRenderString(ctx *markup.RenderContext, content string) (string, error) {
var buf strings.Builder
err := markup.Render(ctx, strings.NewReader(content), &buf)
return buf.String(), err
}
func TestRender_Commits(t *testing.T) {
test := func(input, expected string) {
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
buffer, err := markup.RenderString(rctx, input)
buffer, err := testRenderString(rctx, input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -75,7 +81,7 @@ func TestRender_CrossReferences(t *testing.T) {
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
buffer, err := markup.RenderString(rctx, input)
buffer, err := testRenderString(rctx, input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -119,7 +125,7 @@ func TestRender_links(t *testing.T) {
setting.AppURL = markup.TestAppURL
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -234,7 +240,7 @@ func TestRender_email(t *testing.T) {
setting.AppURL = markup.TestAppURL
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
res, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
res, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res), "input: %s", input)
}
@@ -321,7 +327,7 @@ func TestRender_emoji(t *testing.T) {
test := func(input, expected string) {
expected = strings.ReplaceAll(expected, "&", "&amp;")
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
+2 -1
View File
@@ -77,6 +77,7 @@ func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML {
}
func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt template.HTML, a ...any) error {
_, err := w.Write([]byte(r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))))
htmlStr := r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))
_, err := io.WriteString(w, string(htmlStr))
return err
}
+21
View File
@@ -119,12 +119,33 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(KindDetails, r.renderDetails)
reg.Register(KindSummary, r.renderSummary)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(KindAttention, r.renderAttention)
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
reg.Register(KindRawHTML, r.renderRawHTML)
}
// renderCodeBlock wraps indented code blocks like the fenced renderer
func (r *HTMLRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
opening := r.renderInternal.ProtectSafeAttrs(`<div class="code-block-container code-overflow-scroll"><pre class="code-block"><code>`)
if _, err := w.WriteString(string(opening)); err != nil {
return ast.WalkStop, err
}
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.Writer.RawWrite(w, line.Value(source))
}
} else {
if _, err := w.WriteString("</code></pre></div>"); err != nil {
return ast.WalkStop, err
}
}
return ast.WalkContinue, nil
}
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Document)
+4 -1
View File
@@ -11,6 +11,7 @@ import (
"io"
"strings"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/common"
@@ -266,7 +267,9 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) {
var buf strings.Builder
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
return "", err
log.Warn("Unable to RenderString: %v, content: %s", err, giteautil.TruncateRunes(content, 200))
err = nil
return htmlutil.EscapeString(content), err
}
return template.HTML(buf.String()), nil
}
+3 -1
View File
@@ -601,7 +601,7 @@ func TestMarkdownUlDir(t *testing.T) {
`, string(result))
}
func TestMarkdownFencedCodeBlock(t *testing.T) {
func TestMarkdownCodeBlock(t *testing.T) {
testRender := func(input, expected string) {
buffer, err := markdown.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
@@ -618,4 +618,6 @@ func TestMarkdownFencedCodeBlock(t *testing.T) {
testRender("```js:app.ts\ncode\n```", jsCommon)
testRender("```js,ignore\ncode\n```", jsCommon)
testRender("```js ignore\ncode\n```", jsCommon)
testRender(" code\n", prefix+`<code>code`+nl+`</code>`+suffix)
testRender(" <script>alert(1)</script>\n", prefix+`<code>&lt;script&gt;alert(1)&lt;/script&gt;`+nl+`</code>`+suffix)
}
+5 -9
View File
@@ -106,31 +106,27 @@ func (r *orgWriter) resolveLink(link string) string {
// WriteRegularLink renders images, links or videos
func (r *orgWriter) WriteRegularLink(l org.RegularLink) {
link := r.resolveLink(l.URL)
printHTML := func(html template.HTML, a ...any) {
_, _ = fmt.Fprint(r, htmlutil.HTMLFormat(html, a...))
}
// Inspired by https://github.com/niklasfasching/go-org/blob/6eb20dbda93cb88c3503f7508dc78cbbc639378f/org/html_writer.go#L406-L427
switch l.Kind() {
case "image":
if l.Description == nil {
printHTML(`<img src="%s" alt="%s">`, link, link)
_, _ = htmlutil.HTMLPrintf(r, `<img src="%s" alt="%s">`, link, link)
} else {
imageSrc := r.resolveLink(org.String(l.Description...))
printHTML(`<a href="%s"><img src="%s" alt="%s"></a>`, link, imageSrc, imageSrc)
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><img src="%s" alt="%s"></a>`, link, imageSrc, imageSrc)
}
case "video":
if l.Description == nil {
printHTML(`<video src="%s">%s</video>`, link, link)
_, _ = htmlutil.HTMLPrintf(r, `<video src="%s">%s</video>`, link, link)
} else {
videoSrc := r.resolveLink(org.String(l.Description...))
printHTML(`<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
}
default:
var description any = link
if l.Description != nil {
description = template.HTML(r.WriteNodesAsString(l.Description...)) // orgmode HTMLWriter outputs HTML content
}
printHTML(`<a href="%s">%s</a>`, link, description)
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s">%s</a>`, link, description)
}
}
-9
View File
@@ -198,15 +198,6 @@ func Render(rctx *RenderContext, origInput io.Reader, output io.Writer) error {
return RenderWithRenderer(rctx, renderer, input, output)
}
// RenderString renders Markup string to HTML with all specific handling stuff and return string
func RenderString(ctx *RenderContext, content string) (string, error) {
var buf strings.Builder
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error {
ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"]
refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"]
+3 -1
View File
@@ -40,5 +40,7 @@ type MigrateOptions struct {
MirrorInterval string `json:"mirror_interval"`
AWSAccessKeyID string
AWSSecretAccessKey string
AWSSecretAccessKey string `json:",omitempty"`
AWSSecretAccessKeyEncrypted string `json:"aws_secret_access_key_encrypted,omitempty"`
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (m *Manager) GetLevelDB(connection string) (db *leveldb.DB, err error) {
defer func() {
recovered = recover()
if recovered != nil {
log.Critical("PANIC during GetLevelDB: %v\nStacktrace: %s", recovered, log.Stack(2))
log.Error("PANIC during GetLevelDB: %v\nStacktrace: %s", recovered, log.Stack(2))
}
close(done)
}()
+1 -1
View File
@@ -52,7 +52,7 @@ func (m *Manager) GetRedisClient(connection string) (client redis.UniversalClien
defer func() {
recovered = recover()
if recovered != nil {
log.Critical("PANIC during GetRedisClient: %v\nStacktrace: %s", recovered, log.Stack(2))
log.Error("PANIC during GetRedisClient: %v\nStacktrace: %s", recovered, log.Stack(2))
}
close(done)
}()
+1 -1
View File
@@ -91,7 +91,7 @@ func (e *MarshalEncoder) marshal(v any) error {
val := reflect.ValueOf(v)
typ := reflect.TypeOf(v)
if typ.Kind() == reflect.Ptr {
if typ.Kind() == reflect.Pointer {
val = val.Elem()
typ = typ.Elem()
}
+4 -3
View File
@@ -192,12 +192,13 @@ func isViteDevRequest(req *http.Request) bool {
// Vite uses a path relative to project root and adds "?import" to non-JS/CSS asset imports:
// - {WebSite}/public/assets/... (e.g. SVG icons from "{RepoRoot}/public/assets/img/svg/")
// - {WebSite}/assets/emoji.json: it is an exception for the frontend assets, it is imported by JS code, but:
// - {WebSite}/assets/<file>.json: exception for frontend-imported repo-root assets:
// - KEEP IN MIND: all static frontend assets are served from "{AssetFS}/assets" to "{WebSite}/assets" by Gitea Web Server
// - "{AssetFS}" is a layered filesystem from "{RepoRoot}/public" or embedded assets, and user's custom files in "{CustomPath}/public"
// - "{RepoRoot}/assets/emoji.json" just happens to have the dir name "assets", it is not related to frontend assets
// - "{RepoRoot}/assets/*.json" just happens to live under the dir name "assets"; it is not related to frontend assets
// - BAD DESIGN: indeed it is a "conflicted and polluted name" sample
if path == "/assets/emoji.json" {
switch path {
case "/assets/emoji.json", "/assets/codemirror-languages.json":
return true
}
return false
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/nosql"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -57,10 +58,10 @@ func TestBaseRedis(t *testing.T) {
}()
if !waitRedisReady("redis://127.0.0.1:6379/0", 0) {
redisServer = redisServerCmd(t)
if redisServer == nil && os.Getenv("CI") == "" {
t.Skip("redis-server not found")
return
if redisServer == nil && test.AllowSkipExternalService() {
t.Skip("redis server command not found, skipped")
}
require.NotNil(t, redisServer)
assert.NoError(t, redisServer.Start())
require.True(t, waitRedisReady("redis://127.0.0.1:6379/0", 5*time.Second), "start redis-server")
}
+2 -2
View File
@@ -97,7 +97,7 @@ func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository,
RepoID: repo.ID,
Name: branch,
CommitID: commit.ID.String(),
CommitMessage: commit.Summary(),
CommitMessage: commit.MessageTitle(),
PusherID: doerID,
CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
})
@@ -112,7 +112,7 @@ func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository,
RepoID: repo.ID,
Name: branch,
CommitID: commit.ID.String(),
CommitMessage: commit.Summary(),
CommitMessage: commit.MessageTitle(),
PusherID: doerID,
CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
})
+1 -1
View File
@@ -152,7 +152,7 @@ func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string {
func CommitToPushCommit(commit *git.Commit) *PushCommit {
return &PushCommit{
Sha1: commit.ID.String(),
Message: commit.Message(),
Message: commit.MessageUTF8(),
AuthorEmail: commit.Author.Email,
AuthorName: commit.Author.Name,
CommitterEmail: commit.Committer.Email,
+3 -3
View File
@@ -145,7 +145,7 @@ func TestCommitToPushCommit(t *testing.T) {
ID: sha1,
Author: sig,
Committer: sig,
CommitMessage: "Commit Message",
CommitMessage: git.CommitMessage{MessageRaw: "Commit Message"},
})
assert.Equal(t, hexString, pushCommit.Sha1)
assert.Equal(t, "Commit Message", pushCommit.Message)
@@ -176,13 +176,13 @@ func TestListToPushCommits(t *testing.T) {
ID: hash1,
Author: sig,
Committer: sig,
CommitMessage: "Message1",
CommitMessage: git.CommitMessage{MessageRaw: "Message1"},
},
{
ID: hash2,
Author: sig,
Committer: sig,
CommitMessage: "Message2",
CommitMessage: git.CommitMessage{MessageRaw: "Message2"},
},
}
+17 -7
View File
@@ -8,11 +8,13 @@ import (
"time"
)
const defaultSQLiteBusyTimeout = 20 * 1000
var (
// SupportedDatabaseTypes includes all XORM supported databases type, sqlite3 maybe added by `database_sqlite3.go`
// SupportedDatabaseTypes includes all XORM supported databases type, sqlite3 maybe added by the tag-controlled drivers
SupportedDatabaseTypes = []string{"mysql", "postgres", "mssql"}
// DatabaseTypeNames contains the friendly names for all database types
DatabaseTypeNames = map[string]string{"mysql": "MySQL", "postgres": "PostgreSQL", "mssql": "MSSQL", "sqlite3": "SQLite3"}
DatabaseTypeNames = map[string]string{"mysql": "MySQL", "postgres": "PostgreSQL", "mssql": "MSSQL", DatabaseTypeSQLite3: "SQLite3"}
// Database holds the database settings
Database = struct {
@@ -39,8 +41,8 @@ var (
AutoMigration bool
SlowQueryThreshold time.Duration
}{
SQLiteBusyTimeout: 500,
IterateBufferSize: 50,
IterateBufferSize: 50,
SlowQueryThreshold: 5 * time.Second,
}
)
@@ -63,7 +65,13 @@ func loadDBSetting(rootCfg ConfigProvider) {
Database.CharsetCollation = sec.Key("CHARSET_COLLATION").String()
Database.Path = sec.Key("PATH").MustString(filepath.Join(AppDataPath, "gitea.db"))
Database.SQLiteBusyTimeout = sec.Key("SQLITE_TIMEOUT").MustInt(500)
Database.SQLiteBusyTimeout = sec.Key("SQLITE_TIMEOUT").MustInt(defaultSQLiteBusyTimeout)
// mattn driver isn't really affected by this timeout, but other drivers are affected
// the default value was 500 (0.5s), to avoid breaking existing users, make sure the timeout is long enough (at least, 5 seconds)
if Database.SQLiteBusyTimeout < 5000 {
Database.SQLiteBusyTimeout = defaultSQLiteBusyTimeout
}
Database.SQLiteJournalMode = sec.Key("SQLITE_JOURNAL_MODE").MustString("")
Database.MaxIdleConns = sec.Key("MAX_IDLE_CONNS").MustInt(2)
@@ -79,14 +87,16 @@ func loadDBSetting(rootCfg ConfigProvider) {
Database.DBConnectRetries = sec.Key("DB_RETRIES").MustInt(10)
Database.DBConnectBackoff = sec.Key("DB_RETRY_BACKOFF").MustDuration(3 * time.Second)
Database.AutoMigration = sec.Key("AUTO_MIGRATION").MustBool(true)
Database.SlowQueryThreshold = sec.Key("SLOW_QUERY_THRESHOLD").MustDuration(5 * time.Second)
Database.SlowQueryThreshold = sec.Key("SLOW_QUERY_THRESHOLD").MustDuration(Database.SlowQueryThreshold)
}
// DatabaseType FIXME: it is also used directly with "schemas.DBType", so the names must be consistent
type DatabaseType string
const DatabaseTypeSQLite3 = "sqlite3"
func (t DatabaseType) IsSQLite3() bool {
return t == "sqlite3"
return t == DatabaseTypeSQLite3
}
func (t DatabaseType) IsMySQL() bool {
+6 -15
View File
@@ -12,10 +12,11 @@ import (
"code.gitea.io/gitea/modules/log"
)
const IncomingEmailTokenPlaceholder = "%{token}"
var IncomingEmail = struct {
Enabled bool
ReplyToAddress string
TokenPlaceholder string `ini:"-"`
Host string
Port int
UseTLS bool `ini:"USE_TLS"`
@@ -28,7 +29,6 @@ var IncomingEmail = struct {
}{
Mailbox: "INBOX",
DeleteHandledMessage: true,
TokenPlaceholder: "%{token}",
MaximumMessageSize: 10485760,
}
@@ -54,19 +54,10 @@ func checkReplyToAddress() error {
return errors.New("name must not be set")
}
c := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmail.TokenPlaceholder)
switch c {
case 0:
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
case 1:
default:
return fmt.Errorf("%s must appear only once", IncomingEmail.TokenPlaceholder)
placeholderCount := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmailTokenPlaceholder)
userPart, _, _ := strings.Cut(IncomingEmail.ReplyToAddress, "@")
if placeholderCount != 1 || !strings.Contains(userPart, IncomingEmailTokenPlaceholder) {
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmailTokenPlaceholder)
}
parts := strings.Split(IncomingEmail.ReplyToAddress, "@")
if !strings.Contains(parts[0], IncomingEmail.TokenPlaceholder) {
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
}
return nil
}
+1 -1
View File
@@ -237,7 +237,7 @@ var (
AddCoCommitterTrailers: true,
RetargetChildrenOnMerge: true,
DelayCheckForInactiveDays: 7,
DefaultTitleSource: RepoPRTitleSourceFirstCommit,
DefaultTitleSource: RepoPRTitleSourceAuto,
},
// Issue settings
+27 -20
View File
@@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/user"
"code.gitea.io/gitea/modules/util"
)
// settings
@@ -163,32 +164,38 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
func loadRunModeFrom(rootCfg ConfigProvider) {
rootSec := rootCfg.Section("")
mustNotRunAsRoot(rootSec)
runModeValue := os.Getenv("GITEA_RUN_MODE")
runModeValue = util.IfZero(runModeValue, rootSec.Key("RUN_MODE").String())
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
IsProd = !strings.EqualFold(runModeValue, "dev") // TODO: can use case-sensitive comparing in the future
RunMode = util.Iif(IsProd, "prod", "dev")
// there is a separate check: mustCurrentRunUserMatch (IsRunUserMatchCurrentUser)
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
}
func mustNotRunAsRoot(rootSec ConfigSection) {
if os.Getuid() != 0 {
return
}
mustRunAsRoot := os.Getenv("SNAP") != "" && os.Getenv("SNAP_NAME") != "" // snap container runs the app as uid=0
if mustRunAsRoot {
return
}
// The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches.
// Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly.
unsafeAllowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")
unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value()
RunMode = os.Getenv("GITEA_RUN_MODE")
if RunMode == "" {
RunMode = rootSec.Key("RUN_MODE").MustString("prod")
}
allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config
optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
RunMode = strings.ToLower(RunMode)
if RunMode != "dev" {
RunMode = "prod"
}
IsProd = RunMode != "dev"
// check if we run as root
if os.Getuid() == 0 {
if !unsafeAllowRunAsRoot {
// Special thanks to VLC which inspired the wording of this messaging.
log.Fatal("Gitea is not supposed to be run as root. Sorry. If you need to use privileged TCP ports please instead use setcap and the `cap_net_bind_service` permission")
}
log.Critical("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
if !allowRunAsRoot {
// Special thanks to VLC which inspired the wording of this messaging.
log.Fatal("Gitea is not supposed to be run as root. If you need to use privileged TCP ports please instead use `setcap` and the `cap_net_bind_service` permission.")
}
log.Warn("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
}
// HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet.
+4 -7
View File
@@ -155,7 +155,6 @@ func sessionHandler(session ssh.Session) {
process.SetSysProcAttribute(cmd)
wg := &sync.WaitGroup{}
wg.Add(2)
if err = cmd.Start(); err != nil {
log.Error("SSH: Start: %v", err)
@@ -169,21 +168,19 @@ func sessionHandler(session ssh.Session) {
}
}()
go func() {
defer wg.Done()
wg.Go(func() {
defer stdout.Close()
if _, err := io.Copy(session, stdout); err != nil {
log.Error("Failed to write stdout to session. %s", err)
}
}()
})
go func() {
defer wg.Done()
wg.Go(func() {
defer stderr.Close()
if _, err := io.Copy(session.Stderr(), stderr); err != nil {
log.Error("Failed to write stderr to session. %s", err)
}
}()
})
// Ensure all the output has been written before we wait on the command
// to exit.
+1 -1
View File
@@ -20,7 +20,7 @@ func listen(server *ssh.Server) {
if err != nil {
select {
case <-graceful.GetManager().IsShutdown():
log.Critical("Failed to start SSH server: %v", err)
log.Error("Failed to start SSH server: %v", err)
default:
log.Fatal("Failed to start SSH server: %v", err)
}
+5 -12
View File
@@ -5,25 +5,22 @@ package storage
import (
"io"
"os"
"strings"
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
)
func TestAzureBlobStorage(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("azureBlobStorage not present outside of CI")
return
}
endpoint := test.ExternalServiceHTTP(t, "TEST_AZURESTORAGE_ENDPOINT", "http://devstoreaccount1.azurite.local:10000")
storageType := setting.AzureBlobStorageType
config := &setting.Storage{
AzureBlobConfig: setting.AzureBlobStorageConfig{
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
Endpoint: "http://devstoreaccount1.azurite.local:10000",
Endpoint: endpoint,
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#well-known-storage-account-and-key
AccountName: "devstoreaccount1",
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
@@ -77,15 +74,11 @@ func TestAzureBlobStoragePath(t *testing.T) {
}
func Test_azureBlobObject(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("azureBlobStorage not present outside of CI")
return
}
endpoint := test.ExternalServiceHTTP(t, "TEST_AZURESTORAGE_ENDPOINT", "http://devstoreaccount1.azurite.local:10000")
s, err := NewStorage(setting.AzureBlobStorageType, &setting.Storage{
AzureBlobConfig: setting.AzureBlobStorageConfig{
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
Endpoint: "http://devstoreaccount1.azurite.local:10000",
Endpoint: endpoint,
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#well-known-storage-account-and-key
AccountName: "devstoreaccount1",
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
+3 -5
View File
@@ -11,20 +11,18 @@ import (
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/minio/minio-go/v7"
"github.com/stretchr/testify/assert"
)
func TestMinioStorage(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("minioStorage not present outside of CI")
return
}
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
storageType := setting.MinioStorageType
config := &setting.Storage{
MinioConfig: setting.MinioStorageConfig{
Endpoint: "minio:9000",
Endpoint: endpoint,
AccessKeyID: "123456",
SecretAccessKey: "12345678",
Bucket: "gitea",
+12 -4
View File
@@ -113,11 +113,13 @@ type Repository struct {
AllowRebaseMerge bool `json:"allow_rebase_explicit"`
AllowSquash bool `json:"allow_squash_merge"`
AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge"`
AllowMergeUpdate bool `json:"allow_merge_update"`
AllowRebaseUpdate bool `json:"allow_rebase_update"`
AllowManualMerge bool `json:"allow_manual_merge"`
AutodetectManualMerge bool `json:"autodetect_manual_merge"`
DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"`
DefaultMergeStyle string `json:"default_merge_style"`
DefaultUpdateStyle string `json:"default_update_style"`
DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit"`
AvatarURL string `json:"avatar_url"`
Internal bool `json:"internal"`
@@ -125,10 +127,12 @@ type Repository struct {
// ObjectFormatName of the underlying git repository
ObjectFormatName ObjectFormatName `json:"object_format_name"`
// swagger:strfmt date-time
MirrorUpdated time.Time `json:"mirror_updated"`
RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"`
Topics []string `json:"topics"`
Licenses []string `json:"licenses"`
MirrorUpdated time.Time `json:"mirror_updated"`
// swagger:strfmt date-time
MirrorLastSyncAt time.Time `json:"mirror_last_sync_at"`
RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"`
Topics []string `json:"topics"`
Licenses []string `json:"licenses"`
}
// CreateRepoOption options when creating repository
@@ -222,12 +226,16 @@ type EditRepoOption struct {
AllowManualMerge *bool `json:"allow_manual_merge,omitempty"`
// either `true` to enable AutodetectManualMerge, or `false` to prevent it. Note: In some special cases, misjudgments can occur.
AutodetectManualMerge *bool `json:"autodetect_manual_merge,omitempty"`
// either `true` to allow updating pull request branch by merge, or `false` to prevent it.
AllowMergeUpdate *bool `json:"allow_merge_update,omitempty"`
// either `true` to allow updating pull request branch by rebase, or `false` to prevent it.
AllowRebaseUpdate *bool `json:"allow_rebase_update,omitempty"`
// set to `true` to delete pr branch after merge by default
DefaultDeleteBranchAfterMerge *bool `json:"default_delete_branch_after_merge,omitempty"`
// set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", "squash", or "fast-forward-only".
DefaultMergeStyle *string `json:"default_merge_style,omitempty"`
// set to an update style to be used by this repository: "merge" or "rebase".
DefaultUpdateStyle *string `json:"default_update_style,omitempty"`
// set to `true` to allow edits from maintainers by default
DefaultAllowMaintainerEdit *bool `json:"default_allow_maintainer_edit,omitempty"`
// set to `true` to archive this repository.
+9
View File
@@ -50,6 +50,9 @@ type BranchProtection struct {
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableBypassAllowlist bool `json:"enable_bypass_allowlist"`
BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"`
BypassAllowlistTeams []string `json:"bypass_allowlist_teams"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"`
@@ -90,6 +93,9 @@ type CreateBranchProtectionOption struct {
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableBypassAllowlist bool `json:"enable_bypass_allowlist"`
BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"`
BypassAllowlistTeams []string `json:"bypass_allowlist_teams"`
EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"`
@@ -123,6 +129,9 @@ type EditBranchProtectionOption struct {
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
EnableBypassAllowlist *bool `json:"enable_bypass_allowlist"`
BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"`
BypassAllowlistTeams []string `json:"bypass_allowlist_teams"`
EnableStatusCheck *bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals *int64 `json:"required_approvals"`
+3 -4
View File
@@ -132,10 +132,9 @@ func newFuncMapWebPage() template.FuncMap {
// -----------------------------------------------------------------
// misc (TODO: move them to MiscUtils to avoid bloating the main func map)
"ActionContent2Commits": ActionContent2Commits,
"IsMultilineCommitMessage": isMultilineCommitMessage,
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
"MirrorRemoteAddress": mirrorRemoteAddress,
"ActionContent2Commits": ActionContent2Commits,
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
"MirrorRemoteAddress": mirrorRemoteAddress,
"FilenameIsImage": filenameIsImage,
"TabSizeClass": tabSizeClass,
@@ -54,17 +54,14 @@ func TestScopedTemplateSetFuncMap(t *testing.T) {
out1 := bytes.Buffer{}
out2 := bytes.Buffer{}
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
wg.Go(func() {
err := ts.newExecutor(funcMap1).Execute(&out1, nil)
assert.NoError(t, err)
wg.Done()
}()
go func() {
})
wg.Go(func() {
err := ts.newExecutor(funcMap2).Execute(&out2, nil)
assert.NoError(t, err)
wg.Done()
}()
})
wg.Wait()
assert.Equal(t, "base1\ntest1\nbase1\ntest1", out1.String())
assert.Equal(t, "base2\ntest2\nbase2\ntest2", out2.String())
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
git_model "code.gitea.io/gitea/models/git"
actions_module "code.gitea.io/gitea/modules/actions"
)
type ActionsUtils struct {
ctx context.Context
}
func NewActionsUtils(ctx context.Context) *ActionsUtils {
return &ActionsUtils{ctx: ctx}
}
func (a *ActionsUtils) CommitStatusesToActionsStatuses(statuses []*git_model.CommitStatus) actions_module.CommitActionsStatusMap {
return actions_module.GetCommitActionsStatusMap(a.ctx, statuses)
}
-5
View File
@@ -52,11 +52,6 @@ func sortArrow(normSort, revSort, urlSort string, isDefault bool) template.HTML
return ""
}
// isMultilineCommitMessage checks to see if a commit message contains multiple lines.
func isMultilineCommitMessage(msg string) bool {
return strings.Count(strings.TrimSpace(msg), "\n") >= 1
}
// Actioner describes an action
type Actioner interface {
GetOpType() activities_model.ActionType
+19 -44
View File
@@ -79,20 +79,14 @@ func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, re
// RenderCommitBody extracts the body of a commit message without its title.
func (ut *RenderUtils) RenderCommitBody(msg string, repo *repo.Repository) template.HTML {
msgLine := strings.TrimSpace(msg)
lineEnd := strings.IndexByte(msgLine, '\n')
if lineEnd > 0 {
msgLine = msgLine[lineEnd+1:]
} else {
return ""
}
msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace)
if len(msgLine) == 0 {
_, body, _ := strings.Cut(strings.TrimSpace(msg), "\n")
body = strings.TrimFunc(body, unicode.IsSpace)
if body == "" {
return ""
}
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
htmlContent := template.HTML(template.HTMLEscapeString(msgLine))
htmlContent := template.HTML(template.HTMLEscapeString(body))
renderedMessage, err := markup.PostProcessCommitMessage(rctx, htmlContent)
if err != nil {
log.Error("PostProcessCommitMessage: %v", err)
@@ -127,23 +121,7 @@ func (ut *RenderUtils) RenderIssueSimpleTitle(text string) template.HTML {
return ret
}
func (ut *RenderUtils) RenderLabelWithLink(label *issues_model.Label, link any) template.HTML {
var attrHref template.HTML
switch link.(type) {
case template.URL, string:
attrHref = htmlutil.HTMLFormat(`href="%s"`, link)
default:
panic(fmt.Sprintf("unexpected type %T for link", link))
}
return ut.renderLabelWithTag(label, "a", attrHref)
}
func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
return ut.renderLabelWithTag(label, "span", "")
}
// RenderLabel renders a label
func (ut *RenderUtils) renderLabelWithTag(label *issues_model.Label, tagName, tagAttrs template.HTML) template.HTML {
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
var extraCSSClasses string
textColor := util.ContrastColor(label.Color)
@@ -157,8 +135,8 @@ func (ut *RenderUtils) renderLabelWithTag(label *issues_model.Label, tagName, ta
if labelScope == "" {
// Regular label
return htmlutil.HTMLFormat(`<%s %s class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s"><span class="gt-ellipsis">%s</span></%s>`,
tagName, tagAttrs, extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name), tagName)
return htmlutil.HTMLFormat(`<span class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s"><span class="gt-ellipsis">%s</span></span>`,
extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name))
}
// Scoped label
@@ -193,29 +171,25 @@ func (ut *RenderUtils) renderLabelWithTag(label *issues_model.Label, tagName, ta
if label.ExclusiveOrder > 0 {
// <scope> | <label> | <order>
return htmlutil.HTMLFormat(`<%s %s class="ui label %s scope-parent" data-tooltip-content title="%s">`+
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right">%d</div>`+
`</%s>`,
tagName, tagAttrs,
`</span>`,
extraCSSClasses, descriptionText,
textColor, scopeColor, scopeHTML,
textColor, itemColor, itemHTML,
label.ExclusiveOrder,
tagName)
label.ExclusiveOrder)
}
// <scope> | <label>
return htmlutil.HTMLFormat(`<%s %s class="ui label %s scope-parent" data-tooltip-content title="%s">`+
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
`</%s>`,
tagName, tagAttrs,
`</span>`,
extraCSSClasses, descriptionText,
textColor, scopeColor, scopeHTML,
textColor, itemColor, itemHTML,
tagName)
textColor, itemColor, itemHTML)
}
// RenderEmoji renders html text with emoji post processors
@@ -252,18 +226,19 @@ func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:rev
func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML {
isPullRequest := issue != nil && issue.IsPull
baseLink := fmt.Sprintf("%s/%s", repoLink, util.Iif(isPullRequest, "pulls", "issues"))
var htmlCode strings.Builder
htmlCode.WriteString(`<span class="labels-list">`)
var htmlCode htmlutil.HTMLBuilder
htmlCode.WriteHTML(`<span class="labels-list">`)
for _, label := range labels {
// Protect against nil value in labels - shouldn't happen but would cause a panic if so
if label == nil {
continue
}
link := fmt.Sprintf("%s?labels=%d", baseLink, label.ID)
htmlCode.WriteString(string(ut.RenderLabelWithLink(label, template.URL(link))))
htmlCode.WriteFormat(`<a class="item" href="%s?labels=%d">`, baseLink, label.ID)
htmlCode.WriteHTML(ut.RenderLabel(label))
htmlCode.WriteHTML("</a>")
}
htmlCode.WriteString("</span>")
return template.HTML(htmlCode.String())
htmlCode.WriteHTML("</span>")
return htmlCode.HTMLString()
}
func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize int) template.HTML {
+6 -7
View File
@@ -206,16 +206,15 @@ func TestRenderLabels(t *testing.T) {
expected = `/owner/repo/pulls?labels=123`
assert.Contains(t, ut.RenderLabels([]*issues.Label{label}, "/owner/repo", issue), expected)
expectedLabel := `<a href="&lt;&gt;" class="ui label " style="color: #fff !important; background-color: label-color !important;" data-tooltip-content title=""><span class="gt-ellipsis">label-name</span></a>`
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, "<>")))
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, template.URL("<>"))))
expectedLabel := `<span class="ui label " style="color: #fff !important; background-color: label-color !important;" data-tooltip-content title=""><span class="gt-ellipsis">label-name</span></span>`
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true}
expectedLabel = `<a href="" class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important">&lt;</div><div class="ui label scope-right" style="color: #fff !important; background-color: #000000 !important">&gt;</div></a>`
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, "")))
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important">&lt;</div><div class="ui label scope-right" style="color: #fff !important; background-color: #000000 !important">&gt;</div></span>`
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true, ExclusiveOrder: 1}
expectedLabel = `<a href="" class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important">&lt;</div><div class="ui label scope-middle" style="color: #fff !important; background-color: #000000 !important">&gt;</div><div class="ui label scope-right">1</div></a>`
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, "")))
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important">&lt;</div><div class="ui label scope-middle" style="color: #fff !important; background-color: #000000 !important">&gt;</div><div class="ui label scope-right">1</div></span>`
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
}
func TestUserMention(t *testing.T) {
+41
View File
@@ -11,7 +11,10 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/util"
@@ -141,3 +144,41 @@ func CompressGzip(content string) *bytes.Buffer {
_ = cw.Close()
return buf
}
var AllowSkipExternalService = sync.OnceValue(func() bool {
isLocalTesting := os.Getenv("CI") == ""
ciSkipExternal, _ := strconv.ParseBool(os.Getenv("GITEA_TEST_CI_SKIP_EXTERNAL"))
return isLocalTesting || ciSkipExternal
})
type TestingT interface {
Helper()
Skipf(format string, args ...any)
Errorf(format string, args ...any)
Fatalf(format string, args ...any)
}
func ExternalServiceHTTP(t TestingT, envVarName, def string) string {
t.Helper()
val := util.IfZero(os.Getenv(envVarName), def)
if val == "" {
if AllowSkipExternalService() {
t.Skipf("skipping test because %s is not set", envVarName)
} else {
t.Fatalf("%s is not set, but skipping is not allowed in CI", envVarName)
}
}
// minio's endpoint is "host:port" pattern
testURL := util.Iif(strings.Contains(val, "://"), val, "http://"+val)
resp, err := http.Get(testURL)
if err != nil {
if AllowSkipExternalService() {
t.Skipf("skipping test because %s is not ready", val)
} else {
t.Fatalf("%s is not ready, but skipping is not allowed in CI", val)
}
} else {
_ = resp.Body.Close()
}
return val
}
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"net"
"net/http"
"reflect"
"slices"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/web/routing"
@@ -131,8 +132,8 @@ type middlewareProvider = func(next http.Handler) http.Handler
func executeMiddlewaresHandler(w http.ResponseWriter, r *http.Request, middlewares []middlewareProvider, endpoint http.HandlerFunc) {
handler := endpoint
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler).ServeHTTP
for _, middleware := range slices.Backward(middlewares) {
handler = middleware(handler).ServeHTTP
}
handler(w, r)
}
+2 -2
View File
@@ -29,7 +29,7 @@ func AssignForm(form any, data map[string]any) {
typ := reflect.TypeOf(form)
val := reflect.ValueOf(form)
for typ.Kind() == reflect.Ptr {
for typ.Kind() == reflect.Pointer {
typ = typ.Elem()
val = val.Elem()
}
@@ -104,7 +104,7 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo
data["ErrorMsg"] = l.TrString("form.unknown_error")
typ := reflect.TypeOf(f)
if typ.Kind() == reflect.Ptr {
if typ.Kind() == reflect.Pointer {
typ = typ.Elem()
}
+1 -1
View File
@@ -260,7 +260,7 @@ func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Reques
// do not respond to other requests, to simulate a real sub-path environment
resp.Header().Add("Content-Type", "text/html; charset=utf-8")
resp.WriteHeader(http.StatusNotFound)
_, _ = resp.Write([]byte(htmlutil.HTMLFormat(`404 page not found, sub-path is: <a href="%s">%s</a>`, setting.AppSubURL, setting.AppSubURL)))
_, _ = htmlutil.HTMLPrintf(resp, `404 page not found, sub-path is: <a href="%s">%s</a>`, setting.AppSubURL, setting.AppSubURL)
return
}
normalized = true
+1 -14
View File
@@ -11,19 +11,6 @@ import (
"code.gitea.io/gitea/modules/web/types"
)
// NewLoggerHandler is a handler that will log routing to the router log taking account of
// routing information
func NewLoggerHandler() func(next http.Handler) http.Handler {
manager := requestRecordsManager{
requestRecords: map[uint64]*requestRecord{},
}
manager.startSlowQueryDetector(3 * time.Second)
logger := log.GetLogger("router")
manager.print = logPrinter(logger)
return manager.handler
}
var (
startMessage = log.NewColoredValue("started ", log.DEBUG.ColorAttributes()...)
slowMessage = log.NewColoredValue("slow ", log.WARN.ColorAttributes()...)
@@ -89,7 +76,7 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
}
var status int
if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok {
if v, ok := record.respWriter.(types.ResponseStatusProvider); ok {
status = v.WrittenStatus()
}
logLevel := record.logLevel
+32 -68
View File
@@ -6,7 +6,6 @@ package routing
import (
"context"
"fmt"
"net/http"
"sync"
"time"
@@ -29,26 +28,21 @@ const (
EndEvent
)
// Printer is used to output the log for a request
type Printer func(trigger Event, record *requestRecord)
// logPrinterFunc is used to output the log for a request
type logPrinterFunc func(trigger Event, record *requestRecord)
type requestRecordsManager struct {
print Printer
lock sync.Mutex
requestRecords map[uint64]*requestRecord
count uint64
type loggerRequestManager struct {
logPrint logPrinterFunc
reqRecords sync.Map // it only contains the active requests which haven't been detected as "slow"
}
func (manager *requestRecordsManager) startSlowQueryDetector(threshold time.Duration) {
func (manager *loggerRequestManager) startSlowQueryDetector(threshold time.Duration) {
go graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: SlowQueryDetector", process.SystemProcessType, true)
defer finished()
// This go-routine checks all active requests every second.
// If a request has been running for a long time (eg: /user/events), we also print a log with "still-executing" message
// After the "still-executing" log is printed, the record will be removed from the map to prevent from duplicated logs in future
// We do not care about accurate duration here. It just does the check periodically, 0.5s or 1.5s are all OK.
t := time.NewTicker(time.Second)
for {
@@ -58,69 +52,39 @@ func (manager *requestRecordsManager) startSlowQueryDetector(threshold time.Dura
case <-t.C:
now := time.Now()
var slowRequests []*requestRecord
// find all slow requests with lock
manager.lock.Lock()
for index, record := range manager.requestRecords {
if now.Sub(record.startTime) < threshold {
continue
}
slowRequests = append(slowRequests, record)
delete(manager.requestRecords, index)
}
manager.lock.Unlock()
// print logs for slow requests
for _, record := range slowRequests {
manager.print(StillExecutingEvent, record)
}
manager.reqRecords.Range(func(key, value any) bool {
index, record := key.(uint64), value.(*requestRecord)
if now.Sub(record.startTime) >= threshold {
manager.logPrint(StillExecutingEvent, record)
manager.reqRecords.Delete(index)
}
return true
})
}
}
})
}
func (manager *requestRecordsManager) handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
record := &requestRecord{
startTime: time.Now(),
request: req,
responseWriter: w,
func (manager *loggerRequestManager) handleRequestRecord(record *requestRecord) func() {
manager.reqRecords.Store(record.index, record)
manager.logPrint(StartEvent, record)
return func() {
// just in case there is a panic. now the panics are all recovered in middleware.go
localPanicErr := recover()
if localPanicErr != nil {
record.lock.Lock()
record.panicError = fmt.Errorf("%v\n%s", localPanicErr, log.Stack(2))
record.lock.Unlock()
}
// generate a record index an insert into the map
manager.lock.Lock()
record.index = manager.count
manager.count++
manager.requestRecords[record.index] = record
manager.lock.Unlock()
manager.reqRecords.Delete(record.index)
manager.logPrint(EndEvent, record)
defer func() {
// just in case there is a panic. now the panics are all recovered in middleware.go
localPanicErr := recover()
if localPanicErr != nil {
record.lock.Lock()
record.panicError = fmt.Errorf("%v\n%s", localPanicErr, log.Stack(2))
record.lock.Unlock()
}
// remove from the record map
manager.lock.Lock()
delete(manager.requestRecords, record.index)
manager.lock.Unlock()
// log the end of the request
manager.print(EndEvent, record)
if localPanicErr != nil {
// the panic wasn't recovered before us, so we should pass it up, and let the framework handle the panic error
panic(localPanicErr)
}
}()
req = req.WithContext(context.WithValue(req.Context(), contextKey, record))
manager.print(StartEvent, record)
next.ServeHTTP(w, req)
})
if localPanicErr != nil {
// the panic wasn't recovered before us, so we should pass it up, and let the framework handle the panic error
panic(localPanicErr)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package routing
import (
"context"
"net/http"
"sync/atomic"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
// NewRequestInfoHandler is a handler that saves request info into request context.
// If router logger is enabled, it will also print request logs and detect slow requests.
func NewRequestInfoHandler() func(next http.Handler) http.Handler {
var reqLogger *loggerRequestManager
if setting.IsRouteLogEnabled() {
reqLogger = &loggerRequestManager{
logPrint: logPrinter(log.GetLogger("router")),
}
reqLogger.startSlowQueryDetector(3 * time.Second)
}
var requestCounter atomic.Uint64
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
record := &requestRecord{
index: requestCounter.Add(1),
startTime: time.Now(),
respWriter: w,
}
req = req.WithContext(context.WithValue(req.Context(), contextKey, record))
record.request = req
if reqLogger != nil {
end := reqLogger.handleRequestRecord(record)
defer end()
}
next.ServeHTTP(w, req)
})
}
}
+10 -10
View File
@@ -12,20 +12,20 @@ import (
)
type requestRecord struct {
// index of the record in the records map
index uint64
// immutable fields
startTime time.Time
request *http.Request
responseWriter http.ResponseWriter
index uint64 // unique number (per process) for the request
startTime time.Time
request *http.Request
respWriter http.ResponseWriter
// mutex
lock sync.RWMutex
// mutable fields
// below are mutable fields
funcInfo *FuncInfo
// * for "mark as long polling"
isLongPolling bool
logLevel log.Level
funcInfo *FuncInfo
panicError error
// * for router logger
logLevel log.Level
panicError error
}