Merge remote-tracking branch 'upstream/main' into limit-repo-size

This commit is contained in:
DmitryFrolovTri
2023-04-14 15:34:07 +00:00
633 changed files with 12185 additions and 8618 deletions
+390 -76
View File
@@ -16,8 +16,19 @@ import (
"github.com/gobwas/glob"
"github.com/nektos/act/pkg/jobparser"
"github.com/nektos/act/pkg/model"
"github.com/nektos/act/pkg/workflowpattern"
"gopkg.in/yaml.v3"
)
func init() {
model.OnDecodeNodeError = func(node yaml.Node, out interface{}, err error) {
// Log the error instead of panic or fatal.
// It will be a big job to refactor act/pkg/model to return decode error,
// so we just log the error and return empty value, and improve it later.
log.Error("Failed to decode node %v into %T: %v", node, out, err)
}
}
func ListWorkflows(commit *git.Commit) (git.Entries, error) {
tree, err := commit.SubTree(".gitea/workflows")
if _, ok := err.(git.ErrNotExist); ok {
@@ -104,40 +115,60 @@ func detectMatched(commit *git.Commit, triggedEvent webhook_module.HookEventType
}
switch triggedEvent {
case webhook_module.HookEventCreate,
case // events with no activity types
webhook_module.HookEventCreate,
webhook_module.HookEventDelete,
webhook_module.HookEventFork,
webhook_module.HookEventIssueAssign,
webhook_module.HookEventIssueLabel,
webhook_module.HookEventIssueMilestone,
webhook_module.HookEventPullRequestAssign,
webhook_module.HookEventPullRequestLabel,
webhook_module.HookEventPullRequestMilestone,
webhook_module.HookEventPullRequestComment,
webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected,
webhook_module.HookEventPullRequestReviewComment,
webhook_module.HookEventWiki,
webhook_module.HookEventRepository,
webhook_module.HookEventRelease,
webhook_module.HookEventPackage:
if len(evt.Acts) != 0 {
log.Warn("Ignore unsupported %s event arguments %q", triggedEvent, evt.Acts)
// FIXME: `wiki` event should match `gollum` event
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#gollum
webhook_module.HookEventWiki:
if len(evt.Acts()) != 0 {
log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
}
// no special filter parameters for these events, just return true if name matched
return true
case webhook_module.HookEventPush:
case // push
webhook_module.HookEventPush:
return matchPushEvent(commit, payload.(*api.PushPayload), evt)
case webhook_module.HookEventIssues:
case // issues
webhook_module.HookEventIssues,
webhook_module.HookEventIssueAssign,
webhook_module.HookEventIssueLabel,
webhook_module.HookEventIssueMilestone:
return matchIssuesEvent(commit, payload.(*api.IssuePayload), evt)
case webhook_module.HookEventPullRequest, webhook_module.HookEventPullRequestSync:
case // issue_comment
webhook_module.HookEventIssueComment,
// `pull_request_comment` is same as `issue_comment`
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
webhook_module.HookEventPullRequestComment:
return matchIssueCommentEvent(commit, payload.(*api.IssueCommentPayload), evt)
case // pull_request
webhook_module.HookEventPullRequest,
webhook_module.HookEventPullRequestSync,
webhook_module.HookEventPullRequestAssign,
webhook_module.HookEventPullRequestLabel:
return matchPullRequestEvent(commit, payload.(*api.PullRequestPayload), evt)
case webhook_module.HookEventIssueComment:
return matchIssueCommentEvent(commit, payload.(*api.IssueCommentPayload), evt)
case // pull_request_review
webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected:
return matchPullRequestReviewEvent(commit, payload.(*api.PullRequestPayload), evt)
case // pull_request_review_comment
webhook_module.HookEventPullRequestReviewComment:
return matchPullRequestReviewCommentEvent(commit, payload.(*api.PullRequestPayload), evt)
case // release
webhook_module.HookEventRelease:
return matchReleaseEvent(commit, payload.(*api.ReleasePayload), evt)
case // registry_package
webhook_module.HookEventPackage:
return matchPackageEvent(commit, payload.(*api.PackagePayload), evt)
default:
log.Warn("unsupported event %q", triggedEvent)
@@ -147,61 +178,131 @@ func detectMatched(commit *git.Commit, triggedEvent webhook_module.HookEventType
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts) == 0 {
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
hasBranchFilter := false
hasTagFilter := false
refName := git.RefName(pushPayload.Ref)
// all acts conditions should be satisfied
for cond, vals := range evt.Acts {
for cond, vals := range evt.Acts() {
switch cond {
case "branches", "tags":
refShortName := git.RefName(pushPayload.Ref).ShortName()
for _, val := range vals {
if glob.MustCompile(val, '/').Match(refShortName) {
matchTimes++
break
}
case "branches":
hasBranchFilter = true
if !refName.IsBranch() {
break
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "branches-ignore":
hasBranchFilter = true
if !refName.IsBranch() {
break
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "tags":
hasTagFilter = true
if !refName.IsTag() {
break
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "tags-ignore":
hasTagFilter = true
if !refName.IsTag() {
break
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "paths":
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
for _, val := range vals {
matched := false
for _, file := range filesChanged {
if glob.MustCompile(val, '/').Match(file) {
matched = true
break
}
}
if matched {
matchTimes++
break
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
}
case "paths-ignore":
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
}
default:
log.Warn("push event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts)
// if both branch and tag filter are defined in the workflow only one needs to match
if hasBranchFilter && hasTagFilter {
matchTimes++
}
return matchTimes == len(evt.Acts())
}
func matchIssuesEvent(commit *git.Commit, issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts) == 0 {
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts {
for cond, vals := range evt.Acts() {
switch cond {
case "types":
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issues
// Actions with the same name:
// opened, edited, closed, reopened, assigned, unassigned, milestoned, demilestoned
// Actions need to be converted:
// label_updated -> labeled
// label_cleared -> unlabeled
// Unsupported activity types:
// deleted, transferred, pinned, unpinned, locked, unlocked
action := issuePayload.Action
switch action {
case api.HookIssueLabelUpdated:
action = "labeled"
case api.HookIssueLabelCleared:
action = "unlabeled"
}
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(issuePayload.Action)) {
if glob.MustCompile(val, '/').Match(string(action)) {
matchTimes++
break
}
@@ -210,24 +311,40 @@ func matchIssuesEvent(commit *git.Commit, issuePayload *api.IssuePayload, evt *j
log.Warn("issue event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts)
return matchTimes == len(evt.Acts())
}
func matchPullRequestEvent(commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts) == 0 {
// defaultly, only pull request opened and synchronized will trigger workflow
return prPayload.Action == api.HookIssueSynchronized || prPayload.Action == api.HookIssueOpened
if len(evt.Acts()) == 0 {
// defaultly, only pull request `opened`, `reopened` and `synchronized` will trigger workflow
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
return prPayload.Action == api.HookIssueSynchronized || prPayload.Action == api.HookIssueOpened || prPayload.Action == api.HookIssueReOpened
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts {
for cond, vals := range evt.Acts() {
switch cond {
case "types":
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
// Actions with the same name:
// opened, edited, closed, reopened, assigned, unassigned
// Actions need to be converted:
// synchronized -> synchronize
// label_updated -> labeled
// label_cleared -> unlabeled
// Unsupported activity types:
// converted_to_draft, ready_for_review, locked, unlocked, review_requested, review_request_removed, auto_merge_enabled, auto_merge_disabled
action := prPayload.Action
if prPayload.Action == api.HookIssueSynchronized {
switch action {
case api.HookIssueSynchronized:
action = "synchronize"
case api.HookIssueLabelUpdated:
action = "labeled"
case api.HookIssueLabelCleared:
action = "unlabeled"
}
log.Trace("matching pull_request %s with %v", action, vals)
for _, val := range vals {
@@ -237,50 +354,75 @@ func matchPullRequestEvent(commit *git.Commit, prPayload *api.PullRequestPayload
}
}
case "branches":
refShortName := git.RefName(prPayload.PullRequest.Base.Ref).ShortName()
for _, val := range vals {
if glob.MustCompile(val, '/').Match(refShortName) {
matchTimes++
break
}
refName := git.RefName(prPayload.PullRequest.Base.Ref)
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "branches-ignore":
refName := git.RefName(prPayload.PullRequest.Base.Ref)
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
case "paths":
filesChanged, err := commit.GetFilesChangedSinceCommit(prPayload.PullRequest.Base.Ref)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
for _, val := range vals {
matched := false
for _, file := range filesChanged {
if glob.MustCompile(val, '/').Match(file) {
matched = true
break
}
}
if matched {
matchTimes++
break
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
}
case "paths-ignore":
filesChanged, err := commit.GetFilesChangedSinceCommit(prPayload.PullRequest.Base.Ref)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
matchTimes++
}
}
default:
log.Warn("pull request event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts)
return matchTimes == len(evt.Acts())
}
func matchIssueCommentEvent(commit *git.Commit, issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts) == 0 {
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts {
for cond, vals := range evt.Acts() {
switch cond {
case "types":
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issue_comment
// Actions with the same name:
// created, edited, deleted
// Actions need to be converted:
// NONE
// Unsupported activity types:
// NONE
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
matchTimes++
@@ -288,8 +430,180 @@ func matchIssueCommentEvent(commit *git.Commit, issueCommentPayload *api.IssueCo
}
}
default:
log.Warn("issue comment unsupported condition %q", cond)
log.Warn("issue comment event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts)
return matchTimes == len(evt.Acts())
}
func matchPullRequestReviewEvent(commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts() {
switch cond {
case "types":
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review
// Activity types with the same name:
// NONE
// Activity types need to be converted:
// reviewed -> submitted
// reviewed -> edited
// Unsupported activity types:
// dismissed
actions := make([]string, 0)
if prPayload.Action == api.HookIssueReviewed {
// the `reviewed` HookIssueAction can match the two activity types: `submitted` and `edited`
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review
actions = append(actions, "submitted", "edited")
}
matched := false
for _, val := range vals {
for _, action := range actions {
if glob.MustCompile(val, '/').Match(action) {
matched = true
break
}
}
if matched {
break
}
}
if matched {
matchTimes++
}
default:
log.Warn("pull request review event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts())
}
func matchPullRequestReviewCommentEvent(commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts() {
switch cond {
case "types":
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review_comment
// Activity types with the same name:
// NONE
// Activity types need to be converted:
// reviewed -> created
// reviewed -> edited
// Unsupported activity types:
// deleted
actions := make([]string, 0)
if prPayload.Action == api.HookIssueReviewed {
// the `reviewed` HookIssueAction can match the two activity types: `created` and `edited`
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review_comment
actions = append(actions, "created", "edited")
}
matched := false
for _, val := range vals {
for _, action := range actions {
if glob.MustCompile(val, '/').Match(action) {
matched = true
break
}
}
if matched {
break
}
}
if matched {
matchTimes++
}
default:
log.Warn("pull request review comment event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts())
}
func matchReleaseEvent(commit *git.Commit, payload *api.ReleasePayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts() {
switch cond {
case "types":
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release
// Activity types with the same name:
// published
// Activity types need to be converted:
// updated -> edited
// Unsupported activity types:
// unpublished, created, deleted, prereleased, released
action := payload.Action
switch action {
case api.HookReleaseUpdated:
action = "edited"
}
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) {
matchTimes++
break
}
}
default:
log.Warn("release event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts())
}
func matchPackageEvent(commit *git.Commit, payload *api.PackagePayload, evt *jobparser.Event) bool {
// with no special filter parameters
if len(evt.Acts()) == 0 {
return true
}
matchTimes := 0
// all acts conditions should be satisfied
for cond, vals := range evt.Acts() {
switch cond {
case "types":
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#registry_package
// Activity types with the same name:
// NONE
// Activity types need to be converted:
// created -> published
// Unsupported activity types:
// updated
action := payload.Action
switch action {
case api.HookPackageCreated:
action = "published"
}
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) {
matchTimes++
break
}
}
default:
log.Warn("package event unsupported condition %q", cond)
}
}
return matchTimes == len(evt.Acts())
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"code.gitea.io/gitea/modules/git"
api "code.gitea.io/gitea/modules/structs"
webhook_module "code.gitea.io/gitea/modules/webhook"
"github.com/stretchr/testify/assert"
)
func TestDetectMatched(t *testing.T) {
testCases := []struct {
desc string
commit *git.Commit
triggedEvent webhook_module.HookEventType
payload api.Payloader
yamlOn string
expected bool
}{
{
desc: "HookEventCreate(create) matches githubEventCreate(create)",
triggedEvent: webhook_module.HookEventCreate,
payload: nil,
yamlOn: "on: create",
expected: true,
},
{
desc: "HookEventIssues(issues) `opened` action matches githubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueOpened},
yamlOn: "on: issues",
expected: true,
},
{
desc: "HookEventIssues(issues) `milestoned` action matches githubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
yamlOn: "on: issues",
expected: true,
},
{
desc: "HookEventPullRequestSync(pull_request_sync) matches githubEventPullRequest(pull_request)",
triggedEvent: webhook_module.HookEventPullRequestSync,
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
yamlOn: "on: pull_request",
expected: true,
},
{
desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match githubEventPullRequest(pull_request) with no activity type",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on: pull_request",
expected: false,
},
{
desc: "HookEventPullRequest(pull_request) `label_updated` action matches githubEventPullRequest(pull_request) with `label` activity type",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on:\n pull_request:\n types: [labeled]",
expected: true,
},
{
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches githubEventPullRequestReviewComment(pull_request_review_comment)",
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
expected: true,
},
{
desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match githubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)",
triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
expected: false,
},
{
desc: "HookEventRelease(release) `published` action matches githubEventRelease(release) with `published` activity type",
triggedEvent: webhook_module.HookEventRelease,
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
yamlOn: "on:\n release:\n types: [published]",
expected: true,
},
{
desc: "HookEventPackage(package) `created` action doesn't match githubEventRegistryPackage(registry_package) with `updated` activity type",
triggedEvent: webhook_module.HookEventPackage,
payload: &api.PackagePayload{Action: api.HookPackageCreated},
yamlOn: "on:\n registry_package:\n types: [updated]",
expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
evts, err := GetEventsFromContent([]byte(tc.yamlOn))
assert.NoError(t, err)
assert.Len(t, evts, 1)
assert.Equal(t, tc.expected, detectMatched(tc.commit, tc.triggedEvent, tc.payload, evts[0]))
})
}
}
+260
View File
@@ -0,0 +1,260 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package assetfs
import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/util"
"github.com/fsnotify/fsnotify"
)
// Layer represents a layer in a layered asset file-system. It has a name and works like http.FileSystem
type Layer struct {
name string
fs http.FileSystem
localPath string
}
func (l *Layer) Name() string {
return l.name
}
// Open opens the named file. The caller is responsible for closing the file.
func (l *Layer) Open(name string) (http.File, error) {
return l.fs.Open(name)
}
// Local returns a new Layer with the given name, it serves files from the given local path.
func Local(name, base string, sub ...string) *Layer {
// TODO: the old behavior (StaticRootPath might not be absolute), not ideal, just keep the same as before
// Ideally, the caller should guarantee the base is absolute, guessing a relative path based on the current working directory is unreliable.
base, err := filepath.Abs(base)
if err != nil {
// This should never happen in a real system. If it happens, the user must have already been in trouble: the system is not able to resolve its own paths.
panic(fmt.Sprintf("Unable to get absolute path for %q: %v", base, err))
}
root := util.FilePathJoinAbs(base, sub...)
return &Layer{name: name, fs: http.Dir(root), localPath: root}
}
// Bindata returns a new Layer with the given name, it serves files from the given bindata asset.
func Bindata(name string, fs http.FileSystem) *Layer {
return &Layer{name: name, fs: fs}
}
// LayeredFS is a layered asset file-system. It works like http.FileSystem, but it can have multiple layers.
// The first layer is the top layer, and it will be used first.
// If the file is not found in the top layer, it will be searched in the next layer.
type LayeredFS struct {
layers []*Layer
}
// Layered returns a new LayeredFS with the given layers. The first layer is the top layer.
func Layered(layers ...*Layer) *LayeredFS {
return &LayeredFS{layers: layers}
}
// Open opens the named file. The caller is responsible for closing the file.
func (l *LayeredFS) Open(name string) (http.File, error) {
for _, layer := range l.layers {
f, err := layer.Open(name)
if err == nil || !os.IsNotExist(err) {
return f, err
}
}
return nil, fs.ErrNotExist
}
// ReadFile reads the named file.
func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) {
bs, _, err := l.ReadLayeredFile(elems...)
return bs, err
}
// ReadLayeredFile reads the named file, and returns the layer name.
func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
name := util.PathJoinRel(elems...)
for _, layer := range l.layers {
f, err := layer.Open(name)
if os.IsNotExist(err) {
continue
} else if err != nil {
return nil, layer.name, err
}
bs, err := io.ReadAll(f)
_ = f.Close()
return bs, layer.name, err
}
return nil, "", fs.ErrNotExist
}
func shouldInclude(info fs.FileInfo, fileMode ...bool) bool {
if util.CommonSkip(info.Name()) {
return false
}
if len(fileMode) == 0 {
return true
} else if len(fileMode) == 1 {
return fileMode[0] == !info.Mode().IsDir()
}
panic("too many arguments for fileMode in shouldInclude")
}
func readDir(layer *Layer, name string) ([]fs.FileInfo, error) {
f, err := layer.Open(name)
if os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
defer f.Close()
return f.Readdir(-1)
}
// ListFiles lists files/directories in the given directory. The fileMode controls the returned files.
// * omitted: all files and directories will be returned.
// * true: only files will be returned.
// * false: only directories will be returned.
// The returned files are sorted by name.
func (l *LayeredFS) ListFiles(name string, fileMode ...bool) ([]string, error) {
fileMap := map[string]bool{}
for _, layer := range l.layers {
infos, err := readDir(layer, name)
if err != nil {
return nil, err
}
for _, info := range infos {
if shouldInclude(info, fileMode...) {
fileMap[info.Name()] = true
}
}
}
files := make([]string, 0, len(fileMap))
for file := range fileMap {
files = append(files, file)
}
sort.Strings(files)
return files, nil
}
// ListAllFiles returns files/directories in the given directory, including subdirectories, recursively.
// The fileMode controls the returned files:
// * omitted: all files and directories will be returned.
// * true: only files will be returned.
// * false: only directories will be returned.
// The returned files are sorted by name.
func (l *LayeredFS) ListAllFiles(name string, fileMode ...bool) ([]string, error) {
return listAllFiles(l.layers, name, fileMode...)
}
func listAllFiles(layers []*Layer, name string, fileMode ...bool) ([]string, error) {
fileMap := map[string]bool{}
var list func(dir string) error
list = func(dir string) error {
for _, layer := range layers {
infos, err := readDir(layer, dir)
if err != nil {
return err
}
for _, info := range infos {
path := util.PathJoinRelX(dir, info.Name())
if shouldInclude(info, fileMode...) {
fileMap[path] = true
}
if info.IsDir() {
if err = list(path); err != nil {
return err
}
}
}
}
return nil
}
if err := list(name); err != nil {
return nil, err
}
var files []string
for file := range fileMap {
files = append(files, file)
}
sort.Strings(files)
return files, nil
}
// WatchLocalChanges watches local changes in the file-system. It's used to help to reload assets when the local file-system changes.
func (l *LayeredFS) WatchLocalChanges(ctx context.Context, callback func()) {
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Asset Local FileSystem Watcher", process.SystemProcessType, true)
defer finished()
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Error("Unable to create watcher for asset local file-system: %v", err)
return
}
defer watcher.Close()
for _, layer := range l.layers {
if layer.localPath == "" {
continue
}
layerDirs, err := listAllFiles([]*Layer{layer}, ".", false)
if err != nil {
log.Error("Unable to list directories for asset local file-system %q: %v", layer.localPath, err)
continue
}
for _, dir := range layerDirs {
if err = watcher.Add(util.FilePathJoinAbs(layer.localPath, dir)); err != nil {
log.Error("Unable to watch directory %s: %v", dir, err)
}
}
}
debounce := util.Debounce(100 * time.Millisecond)
for {
select {
case <-ctx.Done():
return
case event, ok := <-watcher.Events:
if !ok {
return
}
log.Trace("Watched asset local file-system had event: %v", event)
debounce(callback)
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Error("Watched asset local file-system had error: %v", err)
}
}
}
// GetFileLayerName returns the name of the first-seen layer that contains the given file.
func (l *LayeredFS) GetFileLayerName(elems ...string) string {
name := util.PathJoinRel(elems...)
for _, layer := range l.layers {
f, err := layer.Open(name)
if os.IsNotExist(err) {
continue
} else if err != nil {
return ""
}
_ = f.Close()
return layer.name
}
return ""
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package assetfs
import (
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLayered(t *testing.T) {
dir := filepath.Join(t.TempDir(), "assetfs-layers")
dir1 := filepath.Join(dir, "l1")
dir2 := filepath.Join(dir, "l2")
mkdir := func(elems ...string) {
assert.NoError(t, os.MkdirAll(filepath.Join(elems...), 0o755))
}
write := func(content string, elems ...string) {
assert.NoError(t, os.WriteFile(filepath.Join(elems...), []byte(content), 0o644))
}
// d1 & f1: only in "l1"; d2 & f2: only in "l2"
// da & fa: in both "l1" and "l2"
mkdir(dir1, "d1")
mkdir(dir1, "da")
mkdir(dir1, "da/sub1")
mkdir(dir2, "d2")
mkdir(dir2, "da")
mkdir(dir2, "da/sub2")
write("dummy", dir1, ".DS_Store")
write("f1", dir1, "f1")
write("fa-1", dir1, "fa")
write("d1-f", dir1, "d1/f")
write("da-f-1", dir1, "da/f")
write("f2", dir2, "f2")
write("fa-2", dir2, "fa")
write("d2-f", dir2, "d2/f")
write("da-f-2", dir2, "da/f")
assets := Layered(Local("l1", dir1), Local("l2", dir2))
f, err := assets.Open("f1")
assert.NoError(t, err)
bs, err := io.ReadAll(f)
assert.NoError(t, err)
assert.EqualValues(t, "f1", string(bs))
_ = f.Close()
assertRead := func(expected string, expectedErr error, elems ...string) {
bs, err := assets.ReadFile(elems...)
if err != nil {
assert.ErrorAs(t, err, &expectedErr)
} else {
assert.NoError(t, err)
assert.Equal(t, expected, string(bs))
}
}
assertRead("f1", nil, "f1")
assertRead("f2", nil, "f2")
assertRead("fa-1", nil, "fa")
assertRead("d1-f", nil, "d1/f")
assertRead("d2-f", nil, "d2/f")
assertRead("da-f-1", nil, "da/f")
assertRead("", fs.ErrNotExist, "no-such")
files, err := assets.ListFiles(".", true)
assert.NoError(t, err)
assert.EqualValues(t, []string{"f1", "f2", "fa"}, files)
files, err = assets.ListFiles(".", false)
assert.NoError(t, err)
assert.EqualValues(t, []string{"d1", "d2", "da"}, files)
files, err = assets.ListFiles(".")
assert.NoError(t, err)
assert.EqualValues(t, []string{"d1", "d2", "da", "f1", "f2", "fa"}, files)
files, err = assets.ListAllFiles(".", true)
assert.NoError(t, err)
assert.EqualValues(t, []string{"d1/f", "d2/f", "da/f", "f1", "f2", "fa"}, files)
files, err = assets.ListAllFiles(".", false)
assert.NoError(t, err)
assert.EqualValues(t, []string{"d1", "d2", "da", "da/sub1", "da/sub2"}, files)
files, err = assets.ListAllFiles(".")
assert.NoError(t, err)
assert.EqualValues(t, []string{
"d1", "d1/f",
"d2", "d2/f",
"da", "da/f", "da/sub1", "da/sub2",
"f1", "f2", "fa",
}, files)
assert.Empty(t, assets.GetFileLayerName("no-such"))
assert.EqualValues(t, "l1", assets.GetFileLayerName("f1"))
assert.EqualValues(t, "l2", assets.GetFileLayerName("f2"))
}
+5 -1
View File
@@ -14,5 +14,9 @@ var Supported = false
// Auth not supported lack of pam tag
func Auth(serviceName, userName, passwd string) (string, error) {
return "", errors.New("PAM not supported")
// bypass the lint on callers: SA4023: this comparison is always true (staticcheck)
if !Supported {
return "", errors.New("PAM not supported")
}
return "", nil
}
-56
View File
@@ -22,7 +22,6 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/dustin/go-humanize"
"github.com/minio/sha256-simd"
@@ -142,61 +141,6 @@ func FileSize(s int64) string {
return humanize.IBytes(uint64(s))
}
// PrettyNumber produces a string form of the given number in base 10 with
// commas after every three orders of magnitude
func PrettyNumber(i interface{}) string {
return humanize.Comma(util.NumberIntoInt64(i))
}
// Subtract deals with subtraction of all types of number.
func Subtract(left, right interface{}) interface{} {
var rleft, rright int64
var fleft, fright float64
isInt := true
switch v := left.(type) {
case int:
rleft = int64(v)
case int8:
rleft = int64(v)
case int16:
rleft = int64(v)
case int32:
rleft = int64(v)
case int64:
rleft = v
case float32:
fleft = float64(v)
isInt = false
case float64:
fleft = v
isInt = false
}
switch v := right.(type) {
case int:
rright = int64(v)
case int8:
rright = int64(v)
case int16:
rright = int64(v)
case int32:
rright = int64(v)
case int64:
rright = v
case float32:
fright = float64(v)
isInt = false
case float64:
fright = v
isInt = false
}
if isInt {
return rleft - rright
}
return fleft + float64(rleft) - (fright + float64(rright))
}
// EllipsisString returns a truncated short string,
// it appends '...' in the end of the length of string is too large.
func EllipsisString(str string, length int) string {
-46
View File
@@ -114,52 +114,6 @@ func TestFileSize(t *testing.T) {
assert.Equal(t, "2.0 EiB", FileSize(size))
}
func TestPrettyNumber(t *testing.T) {
assert.Equal(t, "23,342,432", PrettyNumber(23342432))
assert.Equal(t, "23,342,432", PrettyNumber(int32(23342432)))
assert.Equal(t, "0", PrettyNumber(0))
assert.Equal(t, "-100,000", PrettyNumber(-100000))
}
func TestSubtract(t *testing.T) {
toFloat64 := func(n interface{}) float64 {
switch v := n.(type) {
case int:
return float64(v)
case int8:
return float64(v)
case int16:
return float64(v)
case int32:
return float64(v)
case int64:
return float64(v)
case float32:
return float64(v)
case float64:
return v
default:
return 0.0
}
}
values := []interface{}{
int(-3),
int8(14),
int16(81),
int32(-156),
int64(1528),
float32(3.5),
float64(-15.348),
}
for _, left := range values {
for _, right := range values {
expected := toFloat64(left) - toFloat64(right)
sub := Subtract(left, right)
assert.InDelta(t, expected, sub, 1e-3)
}
}
}
func TestEllipsisString(t *testing.T) {
assert.Equal(t, "...", EllipsisString("foobar", 0))
assert.Equal(t, "...", EllipsisString("foobar", 1))
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"code.gitea.io/gitea/modules/nosql"
"gitea.com/go-chi/cache"
"github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"
)
// RedisCacher represents a redis cache adapter implementation.
+8 -1
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"fmt"
"net"
"net/http"
"strings"
"text/template"
@@ -67,17 +68,23 @@ func AccessLogger() func(http.Handler) http.Handler {
requestID = parseRequestIDFromRequestHeader(req)
}
reqHost, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
reqHost = req.RemoteAddr
}
next.ServeHTTP(w, r)
rw := w.(ResponseWriter)
buf := bytes.NewBuffer([]byte{})
err := logTemplate.Execute(buf, routerLoggerOptions{
err = logTemplate.Execute(buf, routerLoggerOptions{
req: req,
Identity: &identity,
Start: &start,
ResponseWriter: rw,
Ctx: map[string]interface{}{
"RemoteAddr": req.RemoteAddr,
"RemoteHost": reqHost,
"Req": req,
},
RequestID: &requestID,
+2 -2
View File
@@ -67,7 +67,7 @@ func Toggle(options *ToggleOptions) func(ctx *Context) {
}
if !options.SignOutRequired && !options.DisableCSRF && ctx.Req.Method == "POST" {
ctx.csrf.Validate(ctx)
ctx.Csrf.Validate(ctx)
if ctx.Written() {
return
}
@@ -89,7 +89,7 @@ func Toggle(options *ToggleOptions) func(ctx *Context) {
// Redirect to log in page if auto-signin info is provided and has not signed in.
if !options.SignOutRequired && !ctx.IsSigned &&
len(ctx.GetCookie(setting.CookieUserName)) > 0 {
len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 {
if ctx.Req.URL.Path != "/user/events" {
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
}
+51 -108
View File
@@ -16,10 +16,8 @@ import (
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"strings"
texttemplate "text/template"
"time"
"code.gitea.io/gitea/models/db"
@@ -42,14 +40,15 @@ import (
"gitea.com/go-chi/session"
chi "github.com/go-chi/chi/v5"
"github.com/minio/sha256-simd"
"github.com/unrolled/render"
"golang.org/x/crypto/pbkdf2"
)
const CookieNameFlash = "gitea_flash"
// Render represents a template render
type Render interface {
TemplateLookup(tmpl string) *template.Template
HTML(w io.Writer, status int, name string, binding interface{}, htmlOpt ...render.HTMLOptions) error
TemplateLookup(tmpl string) (*template.Template, error)
HTML(w io.Writer, status int, name string, data interface{}) error
}
// Context represents context of a request.
@@ -61,7 +60,7 @@ type Context struct {
Render Render
translation.Locale
Cache cache.Cache
csrf CSRFProtector
Csrf CSRFProtector
Flash *middleware.Flash
Session session.Store
@@ -215,7 +214,7 @@ func (ctx *Context) RedirectToFirst(location ...string) {
ctx.Redirect(setting.AppSubURL + "/")
}
var templateExecutingErr = regexp.MustCompile(`^template: (.*):([1-9][0-9]*):([1-9][0-9]*): executing (?:"(.*)" at <(.*)>: )?`)
const tplStatus500 base.TplName = "status/500"
// HTML calls Context.HTML and renders the template to HTTP response
func (ctx *Context) HTML(status int, name base.TplName) {
@@ -228,38 +227,11 @@ func (ctx *Context) HTML(status int, name base.TplName) {
return strconv.FormatInt(time.Since(tmplStartTime).Nanoseconds()/1e6, 10) + "ms"
}
if err := ctx.Render.HTML(ctx.Resp, status, string(name), templates.BaseVars().Merge(ctx.Data)); err != nil {
if status == http.StatusInternalServerError && name == base.TplName("status/500") {
ctx.PlainText(http.StatusInternalServerError, "Unable to find status/500 template")
if status == http.StatusInternalServerError && name == tplStatus500 {
ctx.PlainText(http.StatusInternalServerError, "Unable to find HTML templates, the template system is not initialized, or Gitea can't find your template files.")
return
}
if execErr, ok := err.(texttemplate.ExecError); ok {
if groups := templateExecutingErr.FindStringSubmatch(err.Error()); len(groups) > 0 {
errorTemplateName, lineStr, posStr := groups[1], groups[2], groups[3]
target := ""
if len(groups) == 6 {
target = groups[5]
}
line, _ := strconv.Atoi(lineStr) // Cannot error out as groups[2] is [1-9][0-9]*
pos, _ := strconv.Atoi(posStr) // Cannot error out as groups[3] is [1-9][0-9]*
filename, filenameErr := templates.GetAssetFilename("templates/" + errorTemplateName + ".tmpl")
if filenameErr != nil {
filename = "(template) " + errorTemplateName
}
if errorTemplateName != string(name) {
filename += " (subtemplate of " + string(name) + ")"
}
err = fmt.Errorf("%w\nin template file %s:\n%s", err, filename, templates.GetLineFromTemplate(errorTemplateName, line, target, pos))
} else {
filename, filenameErr := templates.GetAssetFilename("templates/" + execErr.Name + ".tmpl")
if filenameErr != nil {
filename = "(template) " + execErr.Name
}
if execErr.Name != string(name) {
filename += " (subtemplate of " + string(name) + ")"
}
err = fmt.Errorf("%w\nin template file %s", err, filename)
}
}
err = fmt.Errorf("failed to render template: %s, error: %s", name, templates.HandleTemplateRenderingError(err))
ctx.ServerError("Render failed", err)
}
}
@@ -327,24 +299,25 @@ func (ctx *Context) serverErrorInternal(logMsg string, logErr error) {
return
}
if !setting.IsProd {
// it's safe to show internal error to admin users, and it helps
if !setting.IsProd || (ctx.Doer != nil && ctx.Doer.IsAdmin) {
ctx.Data["ErrorMsg"] = logErr
}
}
ctx.Data["Title"] = "Internal Server Error"
ctx.HTML(http.StatusInternalServerError, base.TplName("status/500"))
ctx.HTML(http.StatusInternalServerError, tplStatus500)
}
// NotFoundOrServerError use error check function to determine if the error
// is about not found. It responds with 404 status code for not found error,
// or error context description for logging purpose of 500 server error.
func (ctx *Context) NotFoundOrServerError(logMsg string, errCheck func(error) bool, err error) {
if errCheck(err) {
ctx.notFoundInternal(logMsg, err)
func (ctx *Context) NotFoundOrServerError(logMsg string, errCheck func(error) bool, logErr error) {
if errCheck(logErr) {
ctx.notFoundInternal(logMsg, logErr)
return
}
ctx.serverErrorInternal(logMsg, err)
ctx.serverErrorInternal(logMsg, logErr)
}
// PlainTextBytes renders bytes as plain text
@@ -483,38 +456,26 @@ func (ctx *Context) Redirect(location string, status ...int) {
http.Redirect(ctx.Resp, ctx.Req, location, code)
}
// SetCookie convenience function to set most cookies consistently
// SetSiteCookie convenience function to set most cookies consistently
// CSRF and a few others are the exception here
func (ctx *Context) SetCookie(name, value string, expiry int) {
middleware.SetCookie(ctx.Resp, name, value,
expiry,
setting.AppSubURL,
setting.SessionConfig.Domain,
setting.SessionConfig.Secure,
true,
middleware.SameSite(setting.SessionConfig.SameSite))
func (ctx *Context) SetSiteCookie(name, value string, maxAge int) {
middleware.SetSiteCookie(ctx.Resp, name, value, maxAge)
}
// DeleteCookie convenience function to delete most cookies consistently
// DeleteSiteCookie convenience function to delete most cookies consistently
// CSRF and a few others are the exception here
func (ctx *Context) DeleteCookie(name string) {
middleware.SetCookie(ctx.Resp, name, "",
-1,
setting.AppSubURL,
setting.SessionConfig.Domain,
setting.SessionConfig.Secure,
true,
middleware.SameSite(setting.SessionConfig.SameSite))
func (ctx *Context) DeleteSiteCookie(name string) {
middleware.SetSiteCookie(ctx.Resp, name, "", -1)
}
// GetCookie returns given cookie value from request header.
func (ctx *Context) GetCookie(name string) string {
return middleware.GetCookie(ctx.Req, name)
// GetSiteCookie returns given cookie value from request header.
func (ctx *Context) GetSiteCookie(name string) string {
return middleware.GetSiteCookie(ctx.Req, name)
}
// GetSuperSecureCookie returns given cookie value from request header with secret string.
func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) {
val := ctx.GetCookie(name)
val := ctx.GetSiteCookie(name)
return ctx.CookieDecrypt(secret, val)
}
@@ -535,10 +496,9 @@ func (ctx *Context) CookieDecrypt(secret, val string) (string, bool) {
}
// SetSuperSecureCookie sets given cookie value to response header with secret string.
func (ctx *Context) SetSuperSecureCookie(secret, name, value string, expiry int) {
func (ctx *Context) SetSuperSecureCookie(secret, name, value string, maxAge int) {
text := ctx.CookieEncrypt(secret, value)
ctx.SetCookie(name, text, expiry)
ctx.SetSiteCookie(name, text, maxAge)
}
// CookieEncrypt encrypts a given value using the provided secret
@@ -554,19 +514,19 @@ func (ctx *Context) CookieEncrypt(secret, value string) string {
// GetCookieInt returns cookie result in int type.
func (ctx *Context) GetCookieInt(name string) int {
r, _ := strconv.Atoi(ctx.GetCookie(name))
r, _ := strconv.Atoi(ctx.GetSiteCookie(name))
return r
}
// GetCookieInt64 returns cookie result in int64 type.
func (ctx *Context) GetCookieInt64(name string) int64 {
r, _ := strconv.ParseInt(ctx.GetCookie(name), 10, 64)
r, _ := strconv.ParseInt(ctx.GetSiteCookie(name), 10, 64)
return r
}
// GetCookieFloat64 returns cookie result in float64 type.
func (ctx *Context) GetCookieFloat64(name string) float64 {
v, _ := strconv.ParseFloat(ctx.GetCookie(name), 64)
v, _ := strconv.ParseFloat(ctx.GetSiteCookie(name), 64)
return v
}
@@ -628,7 +588,9 @@ func (ctx *Context) Value(key interface{}) interface{} {
if key == git.RepositoryContextKey && ctx.Repo != nil {
return ctx.Repo.GitRepo
}
if key == translation.ContextKey && ctx.Locale != nil {
return ctx.Locale
}
return ctx.Req.Context().Value(key)
}
@@ -662,7 +624,10 @@ func WithContext(req *http.Request, ctx *Context) *http.Request {
// GetContext retrieves install context from request
func GetContext(req *http.Request) *Context {
return req.Context().Value(contextKey).(*Context)
if ctx, ok := req.Context().Value(contextKey).(*Context); ok {
return ctx
}
return nil
}
// GetContextUser returns context user
@@ -729,13 +694,13 @@ func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
ctx.Data["Context"] = &ctx
ctx.Req = WithContext(req, &ctx)
ctx.csrf = PrepareCSRFProtector(csrfOpts, &ctx)
ctx.Csrf = PrepareCSRFProtector(csrfOpts, &ctx)
// Get flash.
flashCookie := ctx.GetCookie("macaron_flash")
vals, _ := url.ParseQuery(flashCookie)
if len(vals) > 0 {
f := &middleware.Flash{
// Get the last flash message from cookie
lastFlashCookie := middleware.GetSiteCookie(ctx.Req, CookieNameFlash)
if vals, _ := url.ParseQuery(lastFlashCookie); len(vals) > 0 {
// store last Flash message into the template data, to render it
ctx.Data["Flash"] = &middleware.Flash{
DataStore: &ctx,
Values: vals,
ErrorMsg: vals.Get("error"),
@@ -743,40 +708,18 @@ func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
InfoMsg: vals.Get("info"),
WarningMsg: vals.Get("warning"),
}
ctx.Data["Flash"] = f
}
f := &middleware.Flash{
DataStore: &ctx,
Values: url.Values{},
ErrorMsg: "",
WarningMsg: "",
InfoMsg: "",
SuccessMsg: "",
}
// prepare an empty Flash message for current request
ctx.Flash = &middleware.Flash{DataStore: &ctx, Values: url.Values{}}
ctx.Resp.Before(func(resp ResponseWriter) {
if flash := f.Encode(); len(flash) > 0 {
middleware.SetCookie(resp, "macaron_flash", flash, 0,
setting.SessionConfig.CookiePath,
middleware.Domain(setting.SessionConfig.Domain),
middleware.HTTPOnly(true),
middleware.Secure(setting.SessionConfig.Secure),
middleware.SameSite(setting.SessionConfig.SameSite),
)
return
if val := ctx.Flash.Encode(); val != "" {
middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, val, 0)
} else if lastFlashCookie != "" {
middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, "", -1)
}
middleware.SetCookie(ctx.Resp, "macaron_flash", "", -1,
setting.SessionConfig.CookiePath,
middleware.Domain(setting.SessionConfig.Domain),
middleware.HTTPOnly(true),
middleware.Secure(setting.SessionConfig.Secure),
middleware.SameSite(setting.SessionConfig.SameSite),
)
})
ctx.Flash = f
// If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid.
if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") {
if err := ctx.Req.ParseMultipartForm(setting.Attachment.MaxSize << 20); err != nil && !strings.Contains(err.Error(), "EOF") { // 32MB max size
@@ -788,7 +731,7 @@ func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 0, "no-transform")
ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
ctx.Data["CsrfToken"] = ctx.csrf.GetToken()
ctx.Data["CsrfToken"] = ctx.Csrf.GetToken()
ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + ctx.Data["CsrfToken"].(string) + `">`)
// FIXME: do we really always need these setting? There should be someway to have to avoid having to always set these
+37 -42
View File
@@ -42,37 +42,26 @@ type CSRFProtector interface {
GetToken() string
// Validate validates the token in http context.
Validate(ctx *Context)
// DeleteCookie deletes the cookie
DeleteCookie(ctx *Context)
}
type csrfProtector struct {
// Header name value for setting and getting csrf token.
Header string
// Form name value for setting and getting csrf token.
Form string
// Cookie name value for setting and getting csrf token.
Cookie string
// Cookie domain
CookieDomain string
// Cookie path
CookiePath string
// Cookie HttpOnly flag value used for the csrf token.
CookieHTTPOnly bool
opt CsrfOptions
// Token generated to pass via header, cookie, or hidden form value.
Token string
// This value must be unique per user.
ID string
// Secret used along with the unique id above to generate the Token.
Secret string
}
// GetHeaderName returns the name of the HTTP header for csrf token.
func (c *csrfProtector) GetHeaderName() string {
return c.Header
return c.opt.Header
}
// GetFormName returns the name of the form value for csrf token.
func (c *csrfProtector) GetFormName() string {
return c.Form
return c.opt.Form
}
// GetToken returns the current token. This is typically used
@@ -138,23 +127,32 @@ func prepareDefaultCsrfOptions(opt CsrfOptions) CsrfOptions {
if opt.SessionKey == "" {
opt.SessionKey = "uid"
}
if opt.CookieLifeTime == 0 {
opt.CookieLifeTime = int(CsrfTokenTimeout.Seconds())
}
opt.oldSessionKey = "_old_" + opt.SessionKey
return opt
}
func newCsrfCookie(c *csrfProtector, value string) *http.Cookie {
return &http.Cookie{
Name: c.opt.Cookie,
Value: value,
Path: c.opt.CookiePath,
Domain: c.opt.CookieDomain,
MaxAge: c.opt.CookieLifeTime,
Secure: c.opt.Secure,
HttpOnly: c.opt.CookieHTTPOnly,
SameSite: c.opt.SameSite,
}
}
// PrepareCSRFProtector returns a CSRFProtector to be used for every request.
// Additionally, depending on options set, generated tokens will be sent via Header and/or Cookie.
func PrepareCSRFProtector(opt CsrfOptions, ctx *Context) CSRFProtector {
opt = prepareDefaultCsrfOptions(opt)
x := &csrfProtector{
Secret: opt.Secret,
Header: opt.Header,
Form: opt.Form,
Cookie: opt.Cookie,
CookieDomain: opt.CookieDomain,
CookiePath: opt.CookiePath,
CookieHTTPOnly: opt.CookieHTTPOnly,
}
x := &csrfProtector{opt: opt}
if opt.Origin && len(ctx.Req.Header.Get("Origin")) > 0 {
return x
@@ -175,7 +173,7 @@ func PrepareCSRFProtector(opt CsrfOptions, ctx *Context) CSRFProtector {
oldUID := ctx.Session.Get(opt.oldSessionKey)
uidChanged := oldUID == nil || oldUID.(string) != x.ID
cookieToken := ctx.GetCookie(opt.Cookie)
cookieToken := ctx.GetSiteCookie(opt.Cookie)
needsNew := true
if uidChanged {
@@ -193,21 +191,10 @@ func PrepareCSRFProtector(opt CsrfOptions, ctx *Context) CSRFProtector {
if needsNew {
// FIXME: actionId.
x.Token = GenerateCsrfToken(x.Secret, x.ID, "POST", time.Now())
x.Token = GenerateCsrfToken(x.opt.Secret, x.ID, "POST", time.Now())
if opt.SetCookie {
var expires interface{}
if opt.CookieLifeTime == 0 {
expires = time.Now().Add(CsrfTokenTimeout)
}
middleware.SetCookie(ctx.Resp, opt.Cookie, x.Token,
opt.CookieLifeTime,
opt.CookiePath,
opt.CookieDomain,
opt.Secure,
opt.CookieHTTPOnly,
expires,
middleware.SameSite(opt.SameSite),
)
cookie := newCsrfCookie(x, x.Token)
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
}
}
@@ -218,8 +205,8 @@ func PrepareCSRFProtector(opt CsrfOptions, ctx *Context) CSRFProtector {
}
func (c *csrfProtector) validateToken(ctx *Context, token string) {
if !ValidCsrfToken(token, c.Secret, c.ID, "POST", time.Now()) {
middleware.DeleteCSRFCookie(ctx.Resp)
if !ValidCsrfToken(token, c.opt.Secret, c.ID, "POST", time.Now()) {
c.DeleteCookie(ctx)
if middleware.IsAPIPath(ctx.Req) {
// currently, there should be no access to the APIPath with CSRF token. because templates shouldn't use the `/api/` endpoints.
http.Error(ctx.Resp, "Invalid CSRF token.", http.StatusBadRequest)
@@ -245,3 +232,11 @@ func (c *csrfProtector) Validate(ctx *Context) {
}
c.validateToken(ctx, "") // no csrf token, use an empty token to respond error
}
func (c *csrfProtector) DeleteCookie(ctx *Context) {
if c.opt.SetCookie {
cookie := newCsrfCookie(c, "")
cookie.MaxAge = -1
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
}
}
+10 -18
View File
@@ -92,33 +92,25 @@ func determineAccessMode(ctx *Context) (perm.AccessMode, error) {
return perm.AccessModeNone, nil
}
// TODO: ActionUser permission check
accessMode := perm.AccessModeNone
if ctx.Package.Owner.IsOrganization() {
org := organization.OrgFromUser(ctx.Package.Owner)
// 1. Get user max authorize level for the org (may be none, if user is not member of the org)
if ctx.Doer != nil {
var err error
accessMode, err = org.GetOrgUserMaxAuthorizeLevel(ctx.Doer.ID)
if ctx.Doer != nil && !ctx.Doer.IsGhost() {
// 1. If user is logged in, check all team packages permissions
teams, err := organization.GetUserOrgTeams(ctx, org.ID, ctx.Doer.ID)
if err != nil {
return accessMode, err
}
// If access mode is less than write check every team for more permissions
if accessMode < perm.AccessModeWrite {
teams, err := organization.GetUserOrgTeams(ctx, org.ID, ctx.Doer.ID)
if err != nil {
return accessMode, err
}
for _, t := range teams {
perm := t.UnitAccessMode(ctx, unit.TypePackages)
if accessMode < perm {
accessMode = perm
}
for _, t := range teams {
perm := t.UnitAccessMode(ctx, unit.TypePackages)
if accessMode < perm {
accessMode = perm
}
}
}
// 2. If authorize level is none, check if org is visible to user
if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, ctx.Package.Owner, ctx.Doer) {
} else if organization.HasOrgOrUserVisible(ctx, ctx.Package.Owner, ctx.Doer) {
// 2. If user is non-login, check if org is visible to non-login user
accessMode = perm.AccessModeRead
}
} else {
+129 -11
View File
@@ -8,6 +8,7 @@ import (
"context"
"fmt"
"html"
"io"
"net/http"
"net/url"
"path"
@@ -33,6 +34,7 @@ import (
asymkey_service "code.gitea.io/gitea/services/asymkey"
"github.com/editorconfig/editorconfig-core-go/v2"
"gopkg.in/yaml.v3"
)
// IssueTemplateDirCandidates issue templates directory
@@ -47,6 +49,13 @@ var IssueTemplateDirCandidates = []string{
".gitlab/issue_template",
}
var IssueConfigCandidates = []string{
".gitea/ISSUE_TEMPLATE/config",
".gitea/issue_template/config",
".github/ISSUE_TEMPLATE/config",
".github/issue_template/config",
}
// PullRequest contains information to make a pull request
type PullRequest struct {
BaseRepo *repo_model.Repository
@@ -231,35 +240,34 @@ func (r *Repository) FileExists(path, branch string) (bool, error) {
// GetEditorconfig returns the .editorconfig definition if found in the
// HEAD of the default repo branch.
func (r *Repository) GetEditorconfig(optCommit ...*git.Commit) (*editorconfig.Editorconfig, error) {
func (r *Repository) GetEditorconfig(optCommit ...*git.Commit) (cfg *editorconfig.Editorconfig, warning, err error) {
if r.GitRepo == nil {
return nil, nil
return nil, nil, nil
}
var (
err error
commit *git.Commit
)
var commit *git.Commit
if len(optCommit) != 0 {
commit = optCommit[0]
} else {
commit, err = r.GitRepo.GetBranchCommit(r.Repository.DefaultBranch)
if err != nil {
return nil, err
return nil, nil, err
}
}
treeEntry, err := commit.GetTreeEntryByPath(".editorconfig")
if err != nil {
return nil, err
return nil, nil, err
}
if treeEntry.Blob().Size() >= setting.UI.MaxDisplayFileSize {
return nil, git.ErrNotExist{ID: "", RelPath: ".editorconfig"}
return nil, nil, git.ErrNotExist{ID: "", RelPath: ".editorconfig"}
}
reader, err := treeEntry.Blob().DataAsync()
if err != nil {
return nil, err
return nil, nil, err
}
defer reader.Close()
return editorconfig.Parse(reader)
return editorconfig.ParseGraceful(reader)
}
// RetrieveBaseRepo retrieves base repository
@@ -529,6 +537,11 @@ func RepoAssignment(ctx *Context) (cancel context.CancelFunc) {
ctx.Data["RepoLink"] = ctx.Repo.RepoLink
ctx.Data["RepoRelPath"] = ctx.Repo.Owner.Name + "/" + ctx.Repo.Repository.Name
if setting.EnableFeed {
ctx.Data["EnableFeed"] = true
ctx.Data["FeedURL"] = ctx.Repo.RepoLink
}
unit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeExternalTracker)
if err == nil {
ctx.Data["RepoExternalIssuesLink"] = unit.ExternalTrackerConfig().ExternalTrackerURL
@@ -1088,3 +1101,108 @@ func (ctx *Context) IssueTemplatesErrorsFromDefaultBranch() ([]*api.IssueTemplat
}
return issueTemplates, invalidFiles
}
func GetDefaultIssueConfig() api.IssueConfig {
return api.IssueConfig{
BlankIssuesEnabled: true,
ContactLinks: make([]api.IssueConfigContactLink, 0),
}
}
// GetIssueConfig loads the given issue config file.
// It never returns a nil config.
func (r *Repository) GetIssueConfig(path string, commit *git.Commit) (api.IssueConfig, error) {
if r.GitRepo == nil {
return GetDefaultIssueConfig(), nil
}
var err error
treeEntry, err := commit.GetTreeEntryByPath(path)
if err != nil {
return GetDefaultIssueConfig(), err
}
reader, err := treeEntry.Blob().DataAsync()
if err != nil {
log.Debug("DataAsync: %v", err)
return GetDefaultIssueConfig(), nil
}
defer reader.Close()
configContent, err := io.ReadAll(reader)
if err != nil {
return GetDefaultIssueConfig(), err
}
issueConfig := api.IssueConfig{}
if err := yaml.Unmarshal(configContent, &issueConfig); err != nil {
return GetDefaultIssueConfig(), err
}
for pos, link := range issueConfig.ContactLinks {
if link.Name == "" {
return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing name key", pos+1)
}
if link.URL == "" {
return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing url key", pos+1)
}
if link.About == "" {
return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing about key", pos+1)
}
_, err = url.ParseRequestURI(link.URL)
if err != nil {
return GetDefaultIssueConfig(), fmt.Errorf("%s is not a valid URL", link.URL)
}
}
return issueConfig, nil
}
// IssueConfigFromDefaultBranch returns the issue config for this repo.
// It never returns a nil config.
func (ctx *Context) IssueConfigFromDefaultBranch() (api.IssueConfig, error) {
if ctx.Repo.Repository.IsEmpty {
return GetDefaultIssueConfig(), nil
}
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if err != nil {
return GetDefaultIssueConfig(), err
}
for _, configName := range IssueConfigCandidates {
if _, err := commit.GetTreeEntryByPath(configName + ".yaml"); err == nil {
return ctx.Repo.GetIssueConfig(configName+".yaml", commit)
}
if _, err := commit.GetTreeEntryByPath(configName + ".yml"); err == nil {
return ctx.Repo.GetIssueConfig(configName+".yml", commit)
}
}
return GetDefaultIssueConfig(), nil
}
// IsIssueConfig returns if the given path is a issue config file.
func (r *Repository) IsIssueConfig(path string) bool {
for _, configName := range IssueConfigCandidates {
if path == configName+".yaml" || path == configName+".yml" {
return true
}
}
return false
}
func (ctx *Context) HasIssueTemplatesOrContactLinks() bool {
if len(ctx.IssueTemplatesFromDefaultBranch()) > 0 {
return true
}
issueConfig, _ := ctx.IssueConfigFromDefaultBranch()
return len(issueConfig.ContactLinks) > 0
}
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"os"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/options"
"code.gitea.io/gitea/modules/setting"
)
@@ -79,7 +78,7 @@ func checkConfigurationFiles(ctx context.Context, logger log.Logger, autofix boo
{"Log Root Path", setting.Log.RootPath, true, true, true},
}
if options.IsDynamic() {
if !setting.HasBuiltinBindata {
configurationFiles = append(configurationFiles, configurationFile{"Static File Root Path", setting.StaticRootPath, true, true, false})
}
+31 -23
View File
@@ -40,7 +40,7 @@ const DefaultLocale = "C"
// Command represents a command with its subcommands or arguments.
type Command struct {
name string
prog string
args []string
parentContext context.Context
desc string
@@ -49,10 +49,28 @@ type Command struct {
}
func (c *Command) String() string {
if len(c.args) == 0 {
return c.name
return c.toString(false)
}
func (c *Command) toString(sanitizing bool) string {
// WARNING: this function is for debugging purposes only. It's much better than old code (which only joins args with space),
// It's impossible to make a simple and 100% correct implementation of argument quoting for different platforms.
debugQuote := func(s string) string {
if strings.ContainsAny(s, " `'\"\t\r\n") {
return fmt.Sprintf("%q", s)
}
return s
}
return fmt.Sprintf("%s %s", c.name, strings.Join(c.args, " "))
a := make([]string, 0, len(c.args)+1)
a = append(a, debugQuote(c.prog))
for _, arg := range c.args {
if sanitizing && (strings.Contains(arg, "://") && strings.Contains(arg, "@")) {
a = append(a, debugQuote(util.SanitizeCredentialURLs(arg)))
} else {
a = append(a, debugQuote(arg))
}
}
return strings.Join(a, " ")
}
// NewCommand creates and returns a new Git Command based on given command and arguments.
@@ -67,7 +85,7 @@ func NewCommand(ctx context.Context, args ...internal.CmdArg) *Command {
cargs = append(cargs, string(arg))
}
return &Command{
name: GitExecutable,
prog: GitExecutable,
args: cargs,
parentContext: ctx,
globalArgsLength: len(globalCommandArgs),
@@ -82,7 +100,7 @@ func NewCommandContextNoGlobals(ctx context.Context, args ...internal.CmdArg) *C
cargs = append(cargs, string(arg))
}
return &Command{
name: GitExecutable,
prog: GitExecutable,
args: cargs,
parentContext: ctx,
}
@@ -250,28 +268,18 @@ func (c *Command) Run(opts *RunOpts) error {
}
if len(opts.Dir) == 0 {
log.Debug("%s", c)
log.Debug("git.Command.Run: %s", c)
} else {
log.Debug("%s: %v", opts.Dir, c)
log.Debug("git.Command.RunDir(%s): %s", opts.Dir, c)
}
desc := c.desc
if desc == "" {
args := c.args[c.globalArgsLength:]
var argSensitiveURLIndexes []int
for i, arg := range c.args {
if strings.Contains(arg, "://") && strings.Contains(arg, "@") {
argSensitiveURLIndexes = append(argSensitiveURLIndexes, i)
}
if opts.Dir == "" {
desc = fmt.Sprintf("git: %s", c.toString(true))
} else {
desc = fmt.Sprintf("git(dir:%s): %s", opts.Dir, c.toString(true))
}
if len(argSensitiveURLIndexes) > 0 {
args = make([]string, len(c.args))
copy(args, c.args)
for _, urlArgIndex := range argSensitiveURLIndexes {
args[urlArgIndex] = util.SanitizeCredentialURLs(args[urlArgIndex])
}
}
desc = fmt.Sprintf("%s %s [repo_path: %s]", c.name, strings.Join(args, " "), opts.Dir)
}
var ctx context.Context
@@ -285,7 +293,7 @@ func (c *Command) Run(opts *RunOpts) error {
}
defer finished()
cmd := exec.CommandContext(ctx, c.name, c.args...)
cmd := exec.CommandContext(ctx, c.prog, c.args...)
if opts.Env == nil {
cmd.Env = os.Environ()
} else {
+8
View File
@@ -52,3 +52,11 @@ func TestGitArgument(t *testing.T) {
assert.True(t, isSafeArgumentValue("x"))
assert.False(t, isSafeArgumentValue("-x"))
}
func TestCommandString(t *testing.T) {
cmd := NewCommandContextNoGlobals(context.Background(), "a", "-m msg", "it's a test", `say "hello"`)
assert.EqualValues(t, cmd.prog+` a "-m msg" "it's a test" "say \"hello\""`, cmd.String())
cmd = NewCommandContextNoGlobals(context.Background(), "url: https://a:b@c/")
assert.EqualValues(t, cmd.prog+` "url: https://sanitized-credential@c/"`, cmd.toString(true))
}
+1 -1
View File
@@ -291,7 +291,7 @@ func (c *Commit) SearchCommits(opts SearchCommitsOptions) ([]*Commit, error) {
// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision
func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) {
return c.repo.getFilesChanged(pastCommit, c.ID.String())
return c.repo.GetFilesChangedBetween(pastCommit, c.ID.String())
}
// FileChangedSinceCommit Returns true if the file given has changed since the the past commit
+9 -36
View File
@@ -209,49 +209,22 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
} else {
cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, opts.Remote, opts.Force, opts.Mirror))
}
var outbuf, errbuf strings.Builder
if opts.Timeout == 0 {
opts.Timeout = -1
}
err := cmd.Run(&RunOpts{
Env: opts.Env,
Timeout: opts.Timeout,
Dir: repoPath,
Stdout: &outbuf,
Stderr: &errbuf,
})
stdout, stderr, err := cmd.RunStdString(&RunOpts{Env: opts.Env, Timeout: opts.Timeout, Dir: repoPath})
if err != nil {
if strings.Contains(errbuf.String(), "non-fast-forward") {
return &ErrPushOutOfDate{
StdOut: outbuf.String(),
StdErr: errbuf.String(),
Err: err,
}
} else if strings.Contains(errbuf.String(), "! [remote rejected]") {
err := &ErrPushRejected{
StdOut: outbuf.String(),
StdErr: errbuf.String(),
Err: err,
}
if strings.Contains(stderr, "non-fast-forward") {
return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}
} else if strings.Contains(stderr, "! [remote rejected]") {
err := &ErrPushRejected{StdOut: stdout, StdErr: stderr, Err: err}
err.GenerateMessage()
return err
} else if strings.Contains(errbuf.String(), "matches more than one") {
err := &ErrMoreThanOne{
StdOut: outbuf.String(),
StdErr: errbuf.String(),
Err: err,
}
return err
} else if strings.Contains(stderr, "matches more than one") {
return &ErrMoreThanOne{StdOut: stdout, StdErr: stderr, Err: err}
}
return fmt.Errorf("push failed: %w - %s\n%s", err, stderr, stdout)
}
if errbuf.Len() > 0 && err != nil {
return fmt.Errorf("%w - %s", err, errbuf.String())
}
return err
return nil
}
// CountObject represents repository count objects report
-8
View File
@@ -182,14 +182,6 @@ func (repo *Repository) searchCommits(id SHA1, opts SearchCommitsOptions) ([]*Co
return repo.parsePrettyFormatLogToList(bytes.TrimSuffix(stdout, []byte{'\n'}))
}
func (repo *Repository) getFilesChanged(id1, id2 string) ([]string, error) {
stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(id1, id2).RunStdBytes(&RunOpts{Dir: repo.Path})
if err != nil {
return nil, err
}
return strings.Split(string(stdout), "\n"), nil
}
// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2
// You must ensure that id1 and id2 are valid commit ids.
func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) {
+7 -3
View File
@@ -92,8 +92,11 @@ func (repo *Repository) GetCompareInfo(basePath, baseBranch, headBranch string,
// We have a common base - therefore we know that ... should work
if !fileOnly {
// avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
var logs []byte
logs, _, err = NewCommand(repo.Ctx, "log").AddDynamicArguments(baseCommitID + separator + headBranch).AddArguments(prettyLogFormat).RunStdBytes(&RunOpts{Dir: repo.Path})
logs, _, err = NewCommand(repo.Ctx, "log").AddArguments(prettyLogFormat).
AddDynamicArguments(baseCommitID + separator + headBranch).AddArguments("--").
RunStdBytes(&RunOpts{Dir: repo.Path})
if err != nil {
return nil, err
}
@@ -146,7 +149,8 @@ func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparis
separator = ".."
}
if err := NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base + separator + head).
// avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
if err := NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base + separator + head).AddArguments("--").
Run(&RunOpts{
Dir: repo.Path,
Stdout: w,
@@ -157,7 +161,7 @@ func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparis
// previously it would return the results of git diff -z --name-only base head so let's try that...
w = &lineCountWriter{}
stderr.Reset()
if err = NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base, head).Run(&RunOpts{
if err = NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base, head).AddArguments("--").Run(&RunOpts{
Dir: repo.Path,
Stdout: w,
Stderr: stderr,
+12 -2
View File
@@ -8,6 +8,7 @@ import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
@@ -68,6 +69,11 @@ func (r *Request) SetTimeout(connectTimeout, readWriteTimeout time.Duration) *Re
return r
}
func (r *Request) SetReadWriteTimeout(readWriteTimeout time.Duration) *Request {
r.setting.ReadWriteTimeout = readWriteTimeout
return r
}
// SetTLSClientConfig sets tls connection configurations if visiting https url.
func (r *Request) SetTLSClientConfig(config *tls.Config) *Request {
r.setting.TLSClientConfig = config
@@ -138,11 +144,11 @@ func (r *Request) getResponse() (*http.Response, error) {
r.Body(paramBody)
}
url, err := url.Parse(r.url)
var err error
r.req.URL, err = url.Parse(r.url)
if err != nil {
return nil, err
}
r.req.URL = url
trans := r.setting.Transport
if trans == nil {
@@ -194,3 +200,7 @@ func TimeoutDialer(cTimeout time.Duration) func(ctx context.Context, net, addr s
return conn, nil
}
}
func (r *Request) GoString() string {
return fmt.Sprintf("%s %s", r.req.Method, r.url)
}
+16 -1
View File
@@ -107,7 +107,7 @@ func InitIssueIndexer(syncReindex bool) {
// Create the Queue
switch setting.Indexer.IssueType {
case "bleve", "elasticsearch":
case "bleve", "elasticsearch", "meilisearch":
handler := func(data ...queue.Data) []queue.Data {
indexer := holder.get()
if indexer == nil {
@@ -220,6 +220,21 @@ func InitIssueIndexer(syncReindex bool) {
issueIndexer := &DBIndexer{}
holder.set(issueIndexer)
graceful.GetManager().RunAtTerminate(finished)
case "meilisearch":
graceful.GetManager().RunWithShutdownFns(func(_, atTerminate func(func())) {
pprof.SetGoroutineLabels(ctx)
issueIndexer, err := NewMeilisearchIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
if err != nil {
log.Fatal("Unable to initialize Meilisearch Issue Indexer at connection: %s Error: %v", setting.Indexer.IssueConnStr, err)
}
exist, err := issueIndexer.Init()
if err != nil {
log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err)
}
populate = !exist
holder.set(issueIndexer)
atTerminate(finished)
})
default:
holder.cancel()
log.Fatal("Unknown issue indexer type: %s", setting.Indexer.IssueType)
+183
View File
@@ -0,0 +1,183 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"strconv"
"sync"
"time"
"github.com/meilisearch/meilisearch-go"
)
var _ Indexer = &MeilisearchIndexer{}
// MeilisearchIndexer implements Indexer interface
type MeilisearchIndexer struct {
client *meilisearch.Client
indexerName string
available bool
availabilityCallback func(bool)
stopTimer chan struct{}
lock sync.RWMutex
}
// MeilisearchIndexer creates a new meilisearch indexer
func NewMeilisearchIndexer(url, apiKey, indexerName string) (*MeilisearchIndexer, error) {
client := meilisearch.NewClient(meilisearch.ClientConfig{
Host: url,
APIKey: apiKey,
})
indexer := &MeilisearchIndexer{
client: client,
indexerName: indexerName,
available: true,
stopTimer: make(chan struct{}),
}
ticker := time.NewTicker(10 * time.Second)
go func() {
for {
select {
case <-ticker.C:
indexer.checkAvailability()
case <-indexer.stopTimer:
ticker.Stop()
return
}
}
}()
return indexer, nil
}
// Init will initialize the indexer
func (b *MeilisearchIndexer) Init() (bool, error) {
_, err := b.client.GetIndex(b.indexerName)
if err == nil {
return true, nil
}
_, err = b.client.CreateIndex(&meilisearch.IndexConfig{
Uid: b.indexerName,
PrimaryKey: "id",
})
if err != nil {
return false, b.checkError(err)
}
_, err = b.client.Index(b.indexerName).UpdateFilterableAttributes(&[]string{"repo_id"})
return false, b.checkError(err)
}
// SetAvailabilityChangeCallback sets callback that will be triggered when availability changes
func (b *MeilisearchIndexer) SetAvailabilityChangeCallback(callback func(bool)) {
b.lock.Lock()
defer b.lock.Unlock()
b.availabilityCallback = callback
}
// Ping checks if meilisearch is available
func (b *MeilisearchIndexer) Ping() bool {
b.lock.RLock()
defer b.lock.RUnlock()
return b.available
}
// Index will save the index data
func (b *MeilisearchIndexer) Index(issues []*IndexerData) error {
if len(issues) == 0 {
return nil
}
for _, issue := range issues {
_, err := b.client.Index(b.indexerName).AddDocuments(issue)
if err != nil {
return b.checkError(err)
}
}
// TODO: bulk send index data
return nil
}
// Delete deletes indexes by ids
func (b *MeilisearchIndexer) Delete(ids ...int64) error {
if len(ids) == 0 {
return nil
}
for _, id := range ids {
_, err := b.client.Index(b.indexerName).DeleteDocument(strconv.FormatInt(id, 10))
if err != nil {
return b.checkError(err)
}
}
// TODO: bulk send deletes
return nil
}
// Search searches for issues by given conditions.
// Returns the matching issue IDs
func (b *MeilisearchIndexer) Search(ctx context.Context, keyword string, repoIDs []int64, limit, start int) (*SearchResult, error) {
filter := make([][]string, 0, len(repoIDs))
for _, repoID := range repoIDs {
filter = append(filter, []string{"repo_id = " + strconv.FormatInt(repoID, 10)})
}
searchRes, err := b.client.Index(b.indexerName).Search(keyword, &meilisearch.SearchRequest{
Filter: filter,
Limit: int64(limit),
Offset: int64(start),
})
if err != nil {
return nil, b.checkError(err)
}
hits := make([]Match, 0, len(searchRes.Hits))
for _, hit := range searchRes.Hits {
hits = append(hits, Match{
ID: int64(hit.(map[string]interface{})["id"].(float64)),
})
}
return &SearchResult{
Total: searchRes.TotalHits,
Hits: hits,
}, nil
}
// Close implements indexer
func (b *MeilisearchIndexer) Close() {
select {
case <-b.stopTimer:
default:
close(b.stopTimer)
}
}
func (b *MeilisearchIndexer) checkError(err error) error {
return err
}
func (b *MeilisearchIndexer) checkAvailability() {
_, err := b.client.Health()
if err != nil {
b.setAvailability(false)
return
}
b.setAvailability(true)
}
func (b *MeilisearchIndexer) setAvailability(available bool) {
b.lock.Lock()
defer b.lock.Unlock()
if b.available == available {
return
}
b.available = available
if b.availabilityCallback != nil {
// Call the callback from within the lock to ensure that the ordering remains correct
b.availabilityCallback(b.available)
}
}
+20 -28
View File
@@ -30,31 +30,23 @@ func IsErrTemplateLoad(err error) bool {
}
func (err ErrTemplateLoad) Error() string {
return fmt.Sprintf("Failed to load label template file '%s': %v", err.TemplateFile, err.OriginalError)
return fmt.Sprintf("failed to load label template file %q: %v", err.TemplateFile, err.OriginalError)
}
// GetTemplateFile loads the label template file by given name,
// then parses and returns a list of name-color pairs and optionally description.
func GetTemplateFile(name string) ([]*Label, error) {
data, err := options.Labels(name + ".yaml")
if err == nil && len(data) > 0 {
return parseYamlFormat(name+".yaml", data)
}
data, err = options.Labels(name + ".yml")
if err == nil && len(data) > 0 {
return parseYamlFormat(name+".yml", data)
}
data, err = options.Labels(name)
// LoadTemplateFile loads the label template file by given file name, returns a slice of Label structs.
func LoadTemplateFile(fileName string) ([]*Label, error) {
data, err := options.Labels(fileName)
if err != nil {
return nil, ErrTemplateLoad{name, fmt.Errorf("GetRepoInitFile: %w", err)}
return nil, ErrTemplateLoad{fileName, fmt.Errorf("LoadTemplateFile: %w", err)}
}
return parseLegacyFormat(name, data)
if strings.HasSuffix(fileName, ".yaml") || strings.HasSuffix(fileName, ".yml") {
return parseYamlFormat(fileName, data)
}
return parseLegacyFormat(fileName, data)
}
func parseYamlFormat(name string, data []byte) ([]*Label, error) {
func parseYamlFormat(fileName string, data []byte) ([]*Label, error) {
lf := &labelFile{}
if err := yaml.Unmarshal(data, lf); err != nil {
@@ -65,11 +57,11 @@ func parseYamlFormat(name string, data []byte) ([]*Label, error) {
for _, l := range lf.Labels {
l.Color = strings.TrimSpace(l.Color)
if len(l.Name) == 0 || len(l.Color) == 0 {
return nil, ErrTemplateLoad{name, errors.New("label name and color are required fields")}
return nil, ErrTemplateLoad{fileName, errors.New("label name and color are required fields")}
}
color, err := NormalizeColor(l.Color)
if err != nil {
return nil, ErrTemplateLoad{name, fmt.Errorf("bad HTML color code '%s' in label: %s", l.Color, l.Name)}
return nil, ErrTemplateLoad{fileName, fmt.Errorf("bad HTML color code '%s' in label: %s", l.Color, l.Name)}
}
l.Color = color
}
@@ -77,7 +69,7 @@ func parseYamlFormat(name string, data []byte) ([]*Label, error) {
return lf.Labels, nil
}
func parseLegacyFormat(name string, data []byte) ([]*Label, error) {
func parseLegacyFormat(fileName string, data []byte) ([]*Label, error) {
lines := strings.Split(string(data), "\n")
list := make([]*Label, 0, len(lines))
for i := 0; i < len(lines); i++ {
@@ -88,18 +80,18 @@ func parseLegacyFormat(name string, data []byte) ([]*Label, error) {
parts, description, _ := strings.Cut(line, ";")
color, name, ok := strings.Cut(parts, " ")
color, labelName, ok := strings.Cut(parts, " ")
if !ok {
return nil, ErrTemplateLoad{name, fmt.Errorf("line is malformed: %s", line)}
return nil, ErrTemplateLoad{fileName, fmt.Errorf("line is malformed: %s", line)}
}
color, err := NormalizeColor(color)
if err != nil {
return nil, ErrTemplateLoad{name, fmt.Errorf("bad HTML color code '%s' in line: %s", color, line)}
return nil, ErrTemplateLoad{fileName, fmt.Errorf("bad HTML color code '%s' in line: %s", color, line)}
}
list = append(list, &Label{
Name: strings.TrimSpace(name),
Name: strings.TrimSpace(labelName),
Color: color,
Description: strings.TrimSpace(description),
})
@@ -108,10 +100,10 @@ func parseLegacyFormat(name string, data []byte) ([]*Label, error) {
return list, nil
}
// LoadFormatted loads the labels' list of a template file as a string separated by comma
func LoadFormatted(name string) (string, error) {
// LoadTemplateDescription loads the labels from a template file, returns a description string by joining each Label.Name with comma
func LoadTemplateDescription(fileName string) (string, error) {
var buf strings.Builder
list, err := GetTemplateFile(name)
list, err := LoadTemplateFile(fileName)
if err != nil {
return "", err
}
+30 -12
View File
@@ -60,15 +60,22 @@ func (s *ContentStore) Put(pointer Pointer, r io.Reader) error {
return err
}
// This shouldn't happen but it is sensible to test
if written != pointer.Size {
if err := s.Delete(p); err != nil {
log.Error("Cleaning the LFS OID[%s] failed: %v", pointer.Oid, err)
}
return ErrSizeMismatch
// check again whether there is any error during the Save operation
// because some errors might be ignored by the Reader's caller
if wrappedRd.lastError != nil && !errors.Is(wrappedRd.lastError, io.EOF) {
err = wrappedRd.lastError
} else if written != pointer.Size {
err = ErrSizeMismatch
}
return nil
// if the upload failed, try to delete the file
if err != nil {
if errDel := s.Delete(p); errDel != nil {
log.Error("Cleaning the LFS OID[%s] failed: %v", pointer.Oid, errDel)
}
}
return err
}
// Exists returns true if the object exists in the content store.
@@ -109,6 +116,17 @@ type hashingReader struct {
expectedSize int64
hash hash.Hash
expectedHash string
lastError error
}
// recordError records the last error during the Save operation
// Some callers of the Reader doesn't respect the returned "err"
// For example, MinIO's Put will ignore errors if the written size could equal to expected size
// So we must remember the error by ourselves,
// and later check again whether ErrSizeMismatch or ErrHashMismatch occurs during the Save operation
func (r *hashingReader) recordError(err error) error {
r.lastError = err
return err
}
func (r *hashingReader) Read(b []byte) (int, error) {
@@ -118,22 +136,22 @@ func (r *hashingReader) Read(b []byte) (int, error) {
r.currentSize += int64(n)
wn, werr := r.hash.Write(b[:n])
if wn != n || werr != nil {
return n, werr
return n, r.recordError(werr)
}
}
if err != nil && err == io.EOF {
if errors.Is(err, io.EOF) || r.currentSize >= r.expectedSize {
if r.currentSize != r.expectedSize {
return n, ErrSizeMismatch
return n, r.recordError(ErrSizeMismatch)
}
shaStr := hex.EncodeToString(r.hash.Sum(nil))
if shaStr != r.expectedHash {
return n, ErrHashMismatch
return n, r.recordError(ErrHashMismatch)
}
}
return n, err
return n, r.recordError(err)
}
func newHashingReader(expectedSize int64, expectedHash string, reader io.Reader) *hashingReader {
+37 -7
View File
@@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/regexplru"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates/vars"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
@@ -97,14 +98,30 @@ var issueFullPattern *regexp.Regexp
// Once for to prevent races
var issueFullPatternOnce sync.Once
// regexp for full links to hash comment in pull request files changed tab
var filesChangedFullPattern *regexp.Regexp
// Once for to prevent races
var filesChangedFullPatternOnce sync.Once
func getIssueFullPattern() *regexp.Regexp {
issueFullPatternOnce.Do(func() {
// example: https://domain/org/repo/pulls/27#hash
issueFullPattern = regexp.MustCompile(regexp.QuoteMeta(setting.AppURL) +
`[\w_.-]+/[\w_.-]+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#](\S+)?)?\b`)
})
return issueFullPattern
}
func getFilesChangedFullPattern() *regexp.Regexp {
filesChangedFullPatternOnce.Do(func() {
// example: https://domain/org/repo/pulls/27/files#hash
filesChangedFullPattern = regexp.MustCompile(regexp.QuoteMeta(setting.AppURL) +
`[\w_.-]+/[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`)
})
return filesChangedFullPattern
}
// CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within text
func CustomLinkURLSchemes(schemes []string) {
schemes = append(schemes, "http", "https")
@@ -793,15 +810,30 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
next := node.NextSibling
for node != nil && node != next {
m := getIssueFullPattern().FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
mDiffView := getFilesChangedFullPattern().FindStringSubmatchIndex(node.Data)
// leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files
if mDiffView != nil {
return
}
link := node.Data[m[0]:m[1]]
id := "#" + node.Data[m[2]:m[3]]
text := "#" + node.Data[m[2]:m[3]]
// if m[4] and m[5] is not -1, then link is to a comment
// indicate that in the text by appending (comment)
if m[4] != -1 && m[5] != -1 {
if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok {
text += " " + locale.Tr("repo.from_comment")
} else {
text += " (comment)"
}
}
// extract repo and org name from matched link like
// http://localhost:3000/gituser/myrepo/issues/1
@@ -810,12 +842,10 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
matchRepo := linkParts[len(linkParts)-3]
if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] {
// TODO if m[4]:m[5] is not nil, then link is to a comment,
// and we should indicate that in the text somehow
replaceContent(node, m[0], m[1], createLink(link, id, "ref-issue"))
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue"))
} else {
orgRepoID := matchOrg + "/" + matchRepo + id
replaceContent(node, m[0], m[1], createLink(link, orgRepoID, "ref-issue"))
text = matchOrg + "/" + matchRepo + text
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue"))
}
node = node.NextSibling.NextSibling
}
+6 -2
View File
@@ -330,13 +330,17 @@ func TestRender_FullIssueURLs(t *testing.T) {
test("Look here http://localhost:3000/person/repo/issues/4",
`Look here <a href="http://localhost:3000/person/repo/issues/4" class="ref-issue">person/repo#4</a>`)
test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4</a>`)
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
test("http://localhost:3000/gogits/gogs/issues/4",
`<a href="http://localhost:3000/gogits/gogs/issues/4" class="ref-issue">#4</a>`)
test("http://localhost:3000/gogits/gogs/issues/4 test",
`<a href="http://localhost:3000/gogits/gogs/issues/4" class="ref-issue">#4</a> test`)
test("http://localhost:3000/gogits/gogs/issues/4?a=1&b=2#comment-123 test",
`<a href="http://localhost:3000/gogits/gogs/issues/4?a=1&amp;b=2#comment-123" class="ref-issue">#4</a> test`)
`<a href="http://localhost:3000/gogits/gogs/issues/4?a=1&amp;b=2#comment-123" class="ref-issue">#4 (comment)</a> test`)
test("http://localhost:3000/testOrg/testOrgRepo/pulls/2/files#issuecomment-24",
"http://localhost:3000/testOrg/testOrgRepo/pulls/2/files#issuecomment-24")
test("http://localhost:3000/testOrg/testOrgRepo/pulls/2/files",
"http://localhost:3000/testOrg/testOrgRepo/pulls/2/files")
}
func TestRegExp_sha1CurrentPattern(t *testing.T) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"code.gitea.io/gitea/modules/process"
"github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"
"github.com/syndtr/goleveldb/leveldb"
)
+1 -5
View File
@@ -13,7 +13,7 @@ import (
"code.gitea.io/gitea/modules/log"
"github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"
)
var replacer = strings.NewReplacer("_", "", "-", "")
@@ -193,10 +193,6 @@ func getRedisOptions(uri *url.URL) *redis.UniversalOptions {
opts.MinIdleConns, _ = strconv.Atoi(v[0])
case "pooltimeout":
opts.PoolTimeout = valToTimeDuration(v)
case "idletimeout":
opts.IdleTimeout = valToTimeDuration(v)
case "idlecheckfrequency":
opts.IdleCheckFrequency = valToTimeDuration(v)
case "maxredirects":
opts.MaxRedirects, _ = strconv.Atoi(v[0])
case "readonly":
+13 -105
View File
@@ -4,131 +4,39 @@
package options
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
var directories = make(directorySet)
func CustomAssets() *assetfs.Layer {
return assetfs.Local("custom", setting.CustomPath, "options")
}
func AssetFS() *assetfs.LayeredFS {
return assetfs.Layered(CustomAssets(), BuiltinAssets())
}
// Locale reads the content of a specific locale from static/bindata or custom path.
func Locale(name string) ([]byte, error) {
return fileFromOptionsDir("locale", name)
return AssetFS().ReadFile("locale", name)
}
// Readme reads the content of a specific readme from static/bindata or custom path.
func Readme(name string) ([]byte, error) {
return fileFromOptionsDir("readme", name)
return AssetFS().ReadFile("readme", name)
}
// Gitignore reads the content of a gitignore locale from static/bindata or custom path.
func Gitignore(name string) ([]byte, error) {
return fileFromOptionsDir("gitignore", name)
return AssetFS().ReadFile("gitignore", name)
}
// License reads the content of a specific license from static/bindata or custom path.
func License(name string) ([]byte, error) {
return fileFromOptionsDir("license", name)
return AssetFS().ReadFile("license", name)
}
// Labels reads the content of a specific labels from static/bindata or custom path.
func Labels(name string) ([]byte, error) {
return fileFromOptionsDir("label", name)
}
// WalkLocales reads the content of a specific locale
func WalkLocales(callback func(path, name string, d fs.DirEntry, err error) error) error {
if IsDynamic() {
if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to walk locales. Error: %w", err)
}
}
if err := walkAssetDir(filepath.Join(setting.CustomPath, "options", "locale"), callback); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to walk locales. Error: %w", err)
}
return nil
}
func walkAssetDir(root string, callback func(path, name string, d fs.DirEntry, err error) error) error {
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
// name is the path relative to the root
name := path[len(root):]
if len(name) > 0 && name[0] == '/' {
name = name[1:]
}
if err != nil {
if os.IsNotExist(err) {
return callback(path, name, d, err)
}
return err
}
if util.CommonSkip(d.Name()) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
return callback(path, name, d, err)
}); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to get files for assets in %s: %w", root, err)
}
return nil
}
// mustLocalPathAbs coverts a path to absolute path
// FIXME: the old behavior (StaticRootPath might not be absolute), not ideal, just keep the same as before
func mustLocalPathAbs(s string) string {
abs, err := filepath.Abs(s)
if err != nil {
// This should never happen in a real system. If it happens, the user must have already been in trouble: the system is not able to resolve its own paths.
log.Fatal("Unable to get absolute path for %q: %v", s, err)
}
return abs
}
func joinLocalPaths(baseDirs []string, subDir string, elems ...string) (paths []string) {
abs := make([]string, len(elems)+2)
abs[1] = subDir
copy(abs[2:], elems)
for _, baseDir := range baseDirs {
abs[0] = mustLocalPathAbs(baseDir)
paths = append(paths, util.FilePathJoinAbs(abs...))
}
return paths
}
func listLocalDirIfExist(baseDirs []string, subDir string, elems ...string) (files []string, err error) {
for _, localPath := range joinLocalPaths(baseDirs, subDir, elems...) {
isDir, err := util.IsDir(localPath)
if err != nil {
return nil, fmt.Errorf("unable to check if path %q is a directory. %w", localPath, err)
} else if !isDir {
continue
}
dirFiles, err := util.StatDir(localPath, true)
if err != nil {
return nil, fmt.Errorf("unable to read directory %q. %w", localPath, err)
}
files = append(files, dirFiles...)
}
return files, nil
}
func readLocalFile(baseDirs []string, subDir string, elems ...string) ([]byte, error) {
for _, localPath := range joinLocalPaths(baseDirs, subDir, elems...) {
data, err := os.ReadFile(localPath)
if err == nil {
return data, nil
} else if !os.IsNotExist(err) {
log.Error("Unable to read file %q. Error: %v", localPath, err)
}
}
return nil, os.ErrNotExist
return AssetFS().ReadFile("label", name)
}
+3 -22
View File
@@ -6,29 +6,10 @@
package options
import (
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/setting"
)
// Dir returns all files from static or custom directory.
func Dir(name string) ([]string, error) {
if directories.Filled(name) {
return directories.Get(name), nil
}
result, err := listLocalDirIfExist([]string{setting.CustomPath, setting.StaticRootPath}, "options", name)
if err != nil {
return nil, err
}
return directories.AddAndGet(name, result), nil
}
// fileFromOptionsDir is a helper to read files from custom or static path.
func fileFromOptionsDir(elems ...string) ([]byte, error) {
return readLocalFile([]string{setting.CustomPath, setting.StaticRootPath}, "options", elems...)
}
// IsDynamic will return false when using embedded data (-tags bindata)
func IsDynamic() bool {
return true
func BuiltinAssets() *assetfs.Layer {
return assetfs.Local("builtin(static)", setting.StaticRootPath, "options")
}
-45
View File
@@ -1,45 +0,0 @@
// Copyright 2016 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package options
type directorySet map[string][]string
func (s directorySet) Add(key string, value []string) {
_, ok := s[key]
if !ok {
s[key] = make([]string, 0, len(value))
}
s[key] = append(s[key], value...)
}
func (s directorySet) Get(key string) []string {
_, ok := s[key]
if ok {
result := []string{}
seen := map[string]string{}
for _, val := range s[key] {
if _, ok := seen[val]; !ok {
result = append(result, val)
seen[val] = val
}
}
return result
}
return []string{}
}
func (s directorySet) AddAndGet(key string, value []string) []string {
s.Add(key, value)
return s.Get(key)
}
func (s directorySet) Filled(key string) bool {
return len(s[key]) > 0
}
+3 -92
View File
@@ -6,98 +6,9 @@
package options
import (
"fmt"
"io"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/assetfs"
)
// Dir returns all files from custom directory or bindata.
func Dir(name string) ([]string, error) {
if directories.Filled(name) {
return directories.Get(name), nil
}
result, err := listLocalDirIfExist([]string{setting.CustomPath}, "options", name)
if err != nil {
return nil, err
}
files, err := AssetDir(name)
if err != nil {
return []string{}, fmt.Errorf("unable to read embedded directory %q. %w", name, err)
}
result = append(result, files...)
return directories.AddAndGet(name, result), nil
}
func AssetDir(dirName string) ([]string, error) {
d, err := Assets.Open(dirName)
if err != nil {
return nil, err
}
defer d.Close()
files, err := d.Readdir(-1)
if err != nil {
return nil, err
}
results := make([]string, 0, len(files))
for _, file := range files {
results = append(results, file.Name())
}
return results, nil
}
// fileFromOptionsDir is a helper to read files from custom path or bindata.
func fileFromOptionsDir(elems ...string) ([]byte, error) {
// only try custom dir, no static dir
if data, err := readLocalFile([]string{setting.CustomPath}, "options", elems...); err == nil {
return data, nil
}
f, err := Assets.Open(util.PathJoinRelX(elems...))
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
func Asset(name string) ([]byte, error) {
f, err := Assets.Open("/" + name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
func AssetNames() []string {
realFS := Assets.(vfsgen۰FS)
results := make([]string, 0, len(realFS))
for k := range realFS {
results = append(results, k[1:])
}
return results
}
func AssetIsDir(name string) (bool, error) {
if f, err := Assets.Open("/" + name); err != nil {
return false, err
} else {
defer f.Close()
if fi, err := f.Stat(); err != nil {
return false, err
} else {
return fi.IsDir(), nil
}
}
}
// IsDynamic will return false when using embedded data (-tags bindata)
func IsDynamic() bool {
return false
func BuiltinAssets() *assetfs.Layer {
return assetfs.Bindata("builtin(bindata)", Assets)
}
+7 -1
View File
@@ -62,7 +62,13 @@ type Metadata struct {
DocumentationURL string `json:"documentation_url,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
ImageLayers []string `json:"layer_creation,omitempty"`
MultiArch map[string]string `json:"multiarch,omitempty"`
Manifests []*Manifest `json:"manifests,omitempty"`
}
type Manifest struct {
Platform string `json:"platform"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// ParseImageConfig parses the metadata of an image config
+1 -1
View File
@@ -46,7 +46,7 @@ func TestParseImageConfig(t *testing.T) {
},
metadata.Labels,
)
assert.Empty(t, metadata.MultiArch)
assert.Empty(t, metadata.Manifests)
configHelm := `{"description":"` + description + `", "home": "` + projectURL + `", "sources": ["` + repositoryURL + `"], "maintainers":[{"name":"` + author + `"}]}`
+21 -104
View File
@@ -5,14 +5,11 @@ package private
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
)
@@ -99,126 +96,46 @@ type HookProcReceiveRefResult struct {
}
// HookPreReceive check whether the provided commits are allowed
func HookPreReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (int, string) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/pre-receive/%s/%s",
url.PathEscape(ownerName),
url.PathEscape(repoName),
)
req := newInternalRequest(ctx, reqURL, "POST")
req = req.Header("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(opts)
req.Body(jsonBytes)
req.SetTimeout(60*time.Second, time.Duration(60+len(opts.OldCommitIDs))*time.Second)
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, ""
func HookPreReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/pre-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
req := newInternalRequest(ctx, reqURL, "POST", opts)
req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
_, extra := requestJSONResp(req, &responseText{})
return extra
}
// HookPostReceive updates services and users
func HookPostReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookPostReceiveResult, string) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/post-receive/%s/%s",
url.PathEscape(ownerName),
url.PathEscape(repoName),
)
req := newInternalRequest(ctx, reqURL, "POST")
req = req.Header("Content-Type", "application/json")
req.SetTimeout(60*time.Second, time.Duration(60+len(opts.OldCommitIDs))*time.Second)
jsonBytes, _ := json.Marshal(opts)
req.Body(jsonBytes)
resp, err := req.Response()
if err != nil {
return nil, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, decodeJSONError(resp).Err
}
res := &HookPostReceiveResult{}
_ = json.NewDecoder(resp.Body).Decode(res)
return res, ""
func HookPostReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookPostReceiveResult, ResponseExtra) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/post-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
req := newInternalRequest(ctx, reqURL, "POST", opts)
req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
return requestJSONResp(req, &HookPostReceiveResult{})
}
// HookProcReceive proc-receive hook
func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookProcReceiveResult, error) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/proc-receive/%s/%s",
url.PathEscape(ownerName),
url.PathEscape(repoName),
)
func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookProcReceiveResult, ResponseExtra) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/proc-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
req := newInternalRequest(ctx, reqURL, "POST")
req = req.Header("Content-Type", "application/json")
req.SetTimeout(60*time.Second, time.Duration(60+len(opts.OldCommitIDs))*time.Second)
jsonBytes, _ := json.Marshal(opts)
req.Body(jsonBytes)
resp, err := req.Response()
if err != nil {
return nil, fmt.Errorf("Unable to contact gitea: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New(decodeJSONError(resp).Err)
}
res := &HookProcReceiveResult{}
_ = json.NewDecoder(resp.Body).Decode(res)
return res, nil
req := newInternalRequest(ctx, reqURL, "POST", opts)
req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
return requestJSONResp(req, &HookProcReceiveResult{})
}
// SetDefaultBranch will set the default branch to the provided branch for the provided repository
func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) error {
func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/set-default-branch/%s/%s/%s",
url.PathEscape(ownerName),
url.PathEscape(repoName),
url.PathEscape(branch),
)
req := newInternalRequest(ctx, reqURL, "POST")
req = req.Header("Content-Type", "application/json")
req.SetTimeout(60*time.Second, 60*time.Second)
resp, err := req.Response()
if err != nil {
return fmt.Errorf("Unable to contact gitea: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Error returned from gitea: %v", decodeJSONError(resp).Err)
}
return nil
return requestJSONUserMsg(req, "")
}
// SSHLog sends ssh error log response
func SSHLog(ctx context.Context, isErr bool, msg string) error {
reqURL := setting.LocalURL + "api/internal/ssh/log"
req := newInternalRequest(ctx, reqURL, "POST")
req = req.Header("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(&SSHLogOption{
IsError: isErr,
Message: msg,
})
req.Body(jsonBytes)
req.SetTimeout(60*time.Second, 60*time.Second)
resp, err := req.Response()
if err != nil {
return fmt.Errorf("unable to contact gitea: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Error returned from gitea: %v", decodeJSONError(resp).Err)
}
return nil
req := newInternalRequest(ctx, reqURL, "POST", &SSHLogOption{IsError: isErr, Message: msg})
_, extra := requestJSONResp(req, &responseText{})
return extra.Error
}
+29 -27
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"strings"
"time"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/json"
@@ -19,29 +20,10 @@ import (
"code.gitea.io/gitea/modules/setting"
)
func newRequest(ctx context.Context, url, method, sourceIP string) *httplib.Request {
if setting.InternalToken == "" {
log.Fatal(`The INTERNAL_TOKEN setting is missing from the configuration file: %q.
Ensure you are running in the correct environment or set the correct configuration file with -c.`, setting.CustomConf)
}
return httplib.NewRequest(url, method).
SetContext(ctx).
Header("X-Real-IP", sourceIP).
Header("Authorization", fmt.Sprintf("Bearer %s", setting.InternalToken))
}
// Response internal request response
// Response is used for internal request response (for user message and error message)
type Response struct {
Err string `json:"err"`
}
func decodeJSONError(resp *http.Response) *Response {
var res Response
err := json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
res.Err = err.Error()
}
return &res
Err string `json:"err,omitempty"` // server-side error log message, it won't be exposed to end users
UserMsg string `json:"user_msg,omitempty"` // meaningful error message for end users, it will be shown in git client's output.
}
func getClientIP() string {
@@ -52,11 +34,21 @@ func getClientIP() string {
return strings.Fields(sshConnEnv)[0]
}
func newInternalRequest(ctx context.Context, url, method string) *httplib.Request {
req := newRequest(ctx, url, method, getClientIP()).SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
ServerName: setting.Domain,
})
func newInternalRequest(ctx context.Context, url, method string, body ...any) *httplib.Request {
if setting.InternalToken == "" {
log.Fatal(`The INTERNAL_TOKEN setting is missing from the configuration file: %q.
Ensure you are running in the correct environment or set the correct configuration file with -c.`, setting.CustomConf)
}
req := httplib.NewRequest(url, method).
SetContext(ctx).
Header("X-Real-IP", getClientIP()).
Header("Authorization", fmt.Sprintf("Bearer %s", setting.InternalToken)).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
ServerName: setting.Domain,
})
if setting.Protocol == setting.HTTPUnix {
req.SetTransport(&http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -90,5 +82,15 @@ func newInternalRequest(ctx context.Context, url, method string) *httplib.Reques
},
})
}
if len(body) == 1 {
req.Header("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(body[0])
req.Body(jsonBytes)
} else if len(body) > 1 {
log.Fatal("Too many arguments for newInternalRequest")
}
req.SetTimeout(10*time.Second, 60*time.Second)
return req
}
+6 -29
View File
@@ -6,8 +6,6 @@ package private
import (
"context"
"fmt"
"io"
"net/http"
"code.gitea.io/gitea/modules/setting"
)
@@ -16,39 +14,18 @@ import (
func UpdatePublicKeyInRepo(ctx context.Context, keyID, repoID int64) error {
// Ask for running deliver hook and test pull request tasks.
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/ssh/%d/update/%d", keyID, repoID)
resp, err := newInternalRequest(ctx, reqURL, "POST").Response()
if err != nil {
return err
}
defer resp.Body.Close()
// All 2XX status codes are accepted and others will return an error
if resp.StatusCode/100 != 2 {
return fmt.Errorf("Failed to update public key: %s", decodeJSONError(resp).Err)
}
return nil
req := newInternalRequest(ctx, reqURL, "POST")
_, extra := requestJSONResp(req, &responseText{})
return extra.Error
}
// AuthorizedPublicKeyByContent searches content as prefix (leak e-mail part)
// and returns public key found.
func AuthorizedPublicKeyByContent(ctx context.Context, content string) (string, error) {
func AuthorizedPublicKeyByContent(ctx context.Context, content string) (string, ResponseExtra) {
// Ask for running deliver hook and test pull request tasks.
reqURL := setting.LocalURL + "api/internal/ssh/authorized_keys"
req := newInternalRequest(ctx, reqURL, "POST")
req.Param("content", content)
resp, err := req.Response()
if err != nil {
return "", err
}
defer resp.Body.Close()
// All 2XX status codes are accepted and others will return an error
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Failed to update public key: %s", decodeJSONError(resp).Err)
}
bs, err := io.ReadAll(resp.Body)
return string(bs), err
resp, extra := requestJSONResp(req, &responseText{})
return resp.Text, extra
}
+5 -29
View File
@@ -5,11 +5,7 @@ package private
import (
"context"
"fmt"
"io"
"net/http"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
)
@@ -21,38 +17,18 @@ type Email struct {
}
// SendEmail calls the internal SendEmail function
//
// It accepts a list of usernames.
// If DB contains these users it will send the email to them.
//
// If to list == nil its supposed to send an email to every
// user present in DB
func SendEmail(ctx context.Context, subject, message string, to []string) (int, string) {
// If to list == nil, it's supposed to send emails to every user present in DB
func SendEmail(ctx context.Context, subject, message string, to []string) (string, ResponseExtra) {
reqURL := setting.LocalURL + "api/internal/mail/send"
req := newInternalRequest(ctx, reqURL, "POST")
req = req.Header("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(Email{
req := newInternalRequest(ctx, reqURL, "POST", Email{
Subject: subject,
Message: message,
To: to,
})
req.Body(jsonBytes)
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Response body error: %v", err.Error())
}
users := fmt.Sprintf("%d", len(to))
if len(to) == 0 {
users = "all"
}
return http.StatusOK, fmt.Sprintf("Sent %s email(s) to %s users", body, users)
resp, extra := requestJSONResp(req, &responseText{})
return resp.Text, extra
}
+26 -145
View File
@@ -12,44 +12,21 @@ import (
"strconv"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
)
// Shutdown calls the internal shutdown function
func Shutdown(ctx context.Context) (int, string) {
func Shutdown(ctx context.Context) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/shutdown"
req := newInternalRequest(ctx, reqURL, "POST")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Shutting down"
return requestJSONUserMsg(req, "Shutting down")
}
// Restart calls the internal restart function
func Restart(ctx context.Context) (int, string) {
func Restart(ctx context.Context) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/restart"
req := newInternalRequest(ctx, reqURL, "POST")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Restarting"
return requestJSONUserMsg(req, "Restarting")
}
// FlushOptions represents the options for the flush call
@@ -59,102 +36,41 @@ type FlushOptions struct {
}
// FlushQueues calls the internal flush-queues function
func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) (int, string) {
func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/flush-queues"
req := newInternalRequest(ctx, reqURL, "POST")
req := newInternalRequest(ctx, reqURL, "POST", FlushOptions{Timeout: timeout, NonBlocking: nonBlocking})
if timeout > 0 {
req.SetTimeout(timeout+10*time.Second, timeout+10*time.Second)
req.SetReadWriteTimeout(timeout + 10*time.Second)
}
req = req.Header("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(FlushOptions{
Timeout: timeout,
NonBlocking: nonBlocking,
})
req.Body(jsonBytes)
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Flushed"
return requestJSONUserMsg(req, "Flushed")
}
// PauseLogging pauses logging
func PauseLogging(ctx context.Context) (int, string) {
func PauseLogging(ctx context.Context) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/pause-logging"
req := newInternalRequest(ctx, reqURL, "POST")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Logging Paused"
return requestJSONUserMsg(req, "Logging Paused")
}
// ResumeLogging resumes logging
func ResumeLogging(ctx context.Context) (int, string) {
func ResumeLogging(ctx context.Context) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/resume-logging"
req := newInternalRequest(ctx, reqURL, "POST")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Logging Restarted"
return requestJSONUserMsg(req, "Logging Restarted")
}
// ReleaseReopenLogging releases and reopens logging files
func ReleaseReopenLogging(ctx context.Context) (int, string) {
func ReleaseReopenLogging(ctx context.Context) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/release-and-reopen-logging"
req := newInternalRequest(ctx, reqURL, "POST")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Logging Restarted"
return requestJSONUserMsg(req, "Logging Restarted")
}
// SetLogSQL sets database logging
func SetLogSQL(ctx context.Context, on bool) (int, string) {
func SetLogSQL(ctx context.Context, on bool) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/set-log-sql?on=" + strconv.FormatBool(on)
req := newInternalRequest(ctx, reqURL, "POST")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Log SQL setting set"
return requestJSONUserMsg(req, "Log SQL setting set")
}
// LoggerOptions represents the options for the add logger call
@@ -166,67 +82,32 @@ type LoggerOptions struct {
}
// AddLogger adds a logger
func AddLogger(ctx context.Context, group, name, mode string, config map[string]interface{}) (int, string) {
func AddLogger(ctx context.Context, group, name, mode string, config map[string]interface{}) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/add-logger"
req := newInternalRequest(ctx, reqURL, "POST")
req = req.Header("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(LoggerOptions{
req := newInternalRequest(ctx, reqURL, "POST", LoggerOptions{
Group: group,
Name: name,
Mode: mode,
Config: config,
})
req.Body(jsonBytes)
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Added"
return requestJSONUserMsg(req, "Added")
}
// RemoveLogger removes a logger
func RemoveLogger(ctx context.Context, group, name string) (int, string) {
func RemoveLogger(ctx context.Context, group, name string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/remove-logger/%s/%s", url.PathEscape(group), url.PathEscape(name))
req := newInternalRequest(ctx, reqURL, "POST")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
return http.StatusOK, "Removed"
return requestJSONUserMsg(req, "Removed")
}
// Processes return the current processes from this gitea instance
func Processes(ctx context.Context, out io.Writer, flat, noSystem, stacktraces, json bool, cancel string) (int, string) {
func Processes(ctx context.Context, out io.Writer, flat, noSystem, stacktraces, json bool, cancel string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/processes?flat=%t&no-system=%t&stacktraces=%t&json=%t&cancel-pid=%s", flat, noSystem, stacktraces, json, url.QueryEscape(cancel))
req := newInternalRequest(ctx, reqURL, "GET")
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v", err.Error())
callback := func(resp *http.Response, extra *ResponseExtra) {
_, extra.Error = io.Copy(out, resp.Body)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, decodeJSONError(resp).Err
}
_, err = io.Copy(out, resp.Body)
if err != nil {
return http.StatusInternalServerError, err.Error()
}
return http.StatusOK, ""
_, extra := requestJSONResp(req, &callback)
return extra
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"fmt"
"io"
"net/http"
"unicode"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/json"
)
// responseText is used to get the response as text, instead of parsing it as JSON.
type responseText struct {
Text string
}
// ResponseExtra contains extra information about the response, especially for error responses.
type ResponseExtra struct {
StatusCode int
UserMsg string
Error error
}
type responseCallback func(resp *http.Response, extra *ResponseExtra)
func (re *ResponseExtra) HasError() bool {
return re.Error != nil
}
type responseError struct {
statusCode int
errorString string
}
func (re responseError) Error() string {
if re.errorString == "" {
return fmt.Sprintf("internal API error response, status=%d", re.statusCode)
}
return fmt.Sprintf("internal API error response, status=%d, err=%s", re.statusCode, re.errorString)
}
// requestJSONUserMsg sends a request to the gitea server and then parses the response.
// If the status code is not 2xx, or any error occurs, the ResponseExtra.Error field is guaranteed to be non-nil,
// and the ResponseExtra.UserMsg field will be set to a message for the end user.
//
// * If the "res" is a struct pointer, the response will be parsed as JSON
// * If the "res" is responseText pointer, the response will be stored as text in it
// * If the "res" is responseCallback pointer, the callback function should set the ResponseExtra fields accordingly
func requestJSONResp[T any](req *httplib.Request, res *T) (ret *T, extra ResponseExtra) {
resp, err := req.Response()
if err != nil {
extra.UserMsg = "Internal Server Connection Error"
extra.Error = fmt.Errorf("unable to contact gitea %q: %w", req.GoString(), err)
return nil, extra
}
defer resp.Body.Close()
extra.StatusCode = resp.StatusCode
// if the status code is not 2xx, try to parse the error response
if resp.StatusCode/100 != 2 {
var respErr Response
if err := json.NewDecoder(resp.Body).Decode(&respErr); err != nil {
extra.UserMsg = "Internal Server Error Decoding Failed"
extra.Error = fmt.Errorf("unable to decode error response %q: %w", req.GoString(), err)
return nil, extra
}
extra.UserMsg = respErr.UserMsg
if extra.UserMsg == "" {
extra.UserMsg = "Internal Server Error (no message for end users)"
}
extra.Error = responseError{statusCode: resp.StatusCode, errorString: respErr.Err}
return res, extra
}
// now, the StatusCode must be 2xx
var v any = res
if respText, ok := v.(*responseText); ok {
// get the whole response as a text string
bs, err := io.ReadAll(resp.Body)
if err != nil {
extra.UserMsg = "Internal Server Response Reading Failed"
extra.Error = fmt.Errorf("unable to read response %q: %w", req.GoString(), err)
return nil, extra
}
respText.Text = string(bs)
return res, extra
} else if callback, ok := v.(*responseCallback); ok {
// pass the response to callback, and let the callback update the ResponseExtra
extra.StatusCode = resp.StatusCode
(*callback)(resp, &extra)
return nil, extra
} else if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
// decode the response into the given struct
extra.UserMsg = "Internal Server Response Decoding Failed"
extra.Error = fmt.Errorf("unable to decode response %q: %w", req.GoString(), err)
return nil, extra
}
if respMsg, ok := v.(*Response); ok {
// if the "res" is Response structure, try to get the UserMsg from it and update the ResponseExtra
extra.UserMsg = respMsg.UserMsg
if respMsg.Err != "" {
// usually this shouldn't happen, because the StatusCode is 2xx, there should be no error.
// but we still handle the "err" response, in case some people return error messages by status code 200.
extra.Error = responseError{statusCode: resp.StatusCode, errorString: respMsg.Err}
}
}
return res, extra
}
// requestJSONUserMsg sends a request to the gitea server and then parses the response as private.Response
// If the request succeeds, the successMsg will be used as part of ResponseExtra.UserMsg.
func requestJSONUserMsg(req *httplib.Request, successMsg string) ResponseExtra {
resp, extra := requestJSONResp(req, &Response{})
if extra.HasError() {
return extra
}
if resp.UserMsg == "" {
extra.UserMsg = successMsg // if UserMsg is empty, then use successMsg as userMsg
} else if successMsg != "" {
// else, now UserMsg is not empty, if successMsg is not empty, then append successMsg to UserMsg
if unicode.IsPunct(rune(extra.UserMsg[len(extra.UserMsg)-1])) {
extra.UserMsg = extra.UserMsg + " " + successMsg
} else {
extra.UserMsg = extra.UserMsg + ". " + successMsg
}
}
return extra
}
+4 -30
View File
@@ -6,11 +6,8 @@ package private
import (
"context"
"fmt"
"io"
"net/http"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
)
@@ -24,39 +21,16 @@ type RestoreParams struct {
}
// RestoreRepo calls the internal RestoreRepo function
func RestoreRepo(ctx context.Context, repoDir, ownerName, repoName string, units []string, validation bool) (int, string) {
func RestoreRepo(ctx context.Context, repoDir, ownerName, repoName string, units []string, validation bool) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/restore_repo"
req := newInternalRequest(ctx, reqURL, "POST")
req.SetTimeout(3*time.Second, 0) // since the request will spend much time, don't timeout
req = req.Header("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(RestoreParams{
req := newInternalRequest(ctx, reqURL, "POST", RestoreParams{
RepoDir: repoDir,
OwnerName: ownerName,
RepoName: repoName,
Units: units,
Validation: validation,
})
req.Body(jsonBytes)
resp, err := req.Response()
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Unable to contact gitea: %v, could you confirm it's running?", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
ret := struct {
Err string `json:"err"`
}{}
body, err := io.ReadAll(resp.Body)
if err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Response body error: %v", err.Error())
}
if err := json.Unmarshal(body, &ret); err != nil {
return http.StatusInternalServerError, fmt.Sprintf("Response body Unmarshal error: %v", err.Error())
}
return http.StatusInternalServerError, ret.Err
}
return http.StatusOK, fmt.Sprintf("Restore repo %s/%s successfully", ownerName, repoName)
req.SetTimeout(3*time.Second, 0) // since the request will spend much time, don't timeout
return requestJSONUserMsg(req, fmt.Sprintf("Restore repo %s/%s successfully", ownerName, repoName))
}
+10 -55
View File
@@ -6,13 +6,11 @@ package private
import (
"context"
"fmt"
"net/http"
"net/url"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/perm"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
)
@@ -24,20 +22,11 @@ type KeyAndOwner struct {
// ServNoCommand returns information about the provided key
func ServNoCommand(ctx context.Context, keyID int64) (*asymkey_model.PublicKey, *user_model.User, error) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/none/%d",
keyID)
resp, err := newInternalRequest(ctx, reqURL, "GET").Response()
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("%s", decodeJSONError(resp).Err)
}
var keyAndOwner KeyAndOwner
if err := json.NewDecoder(resp.Body).Decode(&keyAndOwner); err != nil {
return nil, nil, err
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/none/%d", keyID)
req := newInternalRequest(ctx, reqURL, "GET")
keyAndOwner, extra := requestJSONResp(req, &KeyAndOwner{})
if extra.HasError() {
return nil, nil, extra.Error
}
return keyAndOwner.Key, keyAndOwner.Owner, nil
}
@@ -56,53 +45,19 @@ type ServCommandResults struct {
RepoID int64
}
// ErrServCommand is an error returned from ServCommmand.
type ErrServCommand struct {
Results ServCommandResults
Err string
StatusCode int
}
func (err ErrServCommand) Error() string {
return err.Err
}
// IsErrServCommand checks if an error is a ErrServCommand.
func IsErrServCommand(err error) bool {
_, ok := err.(ErrServCommand)
return ok
}
// ServCommand preps for a serv call
func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, mode perm.AccessMode, verbs ...string) (*ServCommandResults, error) {
func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, mode perm.AccessMode, verbs ...string) (*ServCommandResults, ResponseExtra) {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/command/%d/%s/%s?mode=%d",
keyID,
url.PathEscape(ownerName),
url.PathEscape(repoName),
mode)
mode,
)
for _, verb := range verbs {
if verb != "" {
reqURL += fmt.Sprintf("&verb=%s", url.QueryEscape(verb))
}
}
resp, err := newInternalRequest(ctx, reqURL, "GET").Response()
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errServCommand ErrServCommand
if err := json.NewDecoder(resp.Body).Decode(&errServCommand); err != nil {
return nil, err
}
errServCommand.StatusCode = resp.StatusCode
return nil, errServCommand
}
var results ServCommandResults
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return nil, err
}
return &results, nil
req := newInternalRequest(ctx, reqURL, "GET")
return requestJSONResp(req, &ServCommandResults{})
}
+50 -44
View File
@@ -4,11 +4,15 @@
package public
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"path"
"strings"
"time"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
@@ -16,55 +20,31 @@ import (
"code.gitea.io/gitea/modules/util"
)
// Options represents the available options to configure the handler.
type Options struct {
Directory string
Prefix string
CorsHandler func(http.Handler) http.Handler
func CustomAssets() *assetfs.Layer {
return assetfs.Local("custom", setting.CustomPath, "public")
}
// AssetsURLPathPrefix is the path prefix for static asset files
const AssetsURLPathPrefix = "/assets/"
func AssetFS() *assetfs.LayeredFS {
return assetfs.Layered(CustomAssets(), BuiltinAssets())
}
// AssetsHandlerFunc implements the static handler for serving custom or original assets.
func AssetsHandlerFunc(opts *Options) http.HandlerFunc {
custPath := filepath.Join(setting.CustomPath, "public")
if !filepath.IsAbs(custPath) {
custPath = filepath.Join(setting.AppWorkPath, custPath)
}
if !filepath.IsAbs(opts.Directory) {
opts.Directory = filepath.Join(setting.AppWorkPath, opts.Directory)
}
if !strings.HasSuffix(opts.Prefix, "/") {
opts.Prefix += "/"
}
func AssetsHandlerFunc(prefix string) http.HandlerFunc {
assetFS := AssetFS()
prefix = strings.TrimSuffix(prefix, "/") + "/"
return func(resp http.ResponseWriter, req *http.Request) {
subPath := req.URL.Path
if !strings.HasPrefix(subPath, prefix) {
return
}
subPath = strings.TrimPrefix(subPath, prefix)
if req.Method != "GET" && req.Method != "HEAD" {
resp.WriteHeader(http.StatusNotFound)
return
}
var corsSent bool
if opts.CorsHandler != nil {
opts.CorsHandler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
corsSent = true
})).ServeHTTP(resp, req)
}
// If CORS is not sent, the response must have been written by other handlers
if !corsSent {
return
}
file := req.URL.Path[len(opts.Prefix):]
// custom files
if opts.handle(resp, req, http.Dir(custPath), file) {
return
}
// internal files
if opts.handle(resp, req, fileSystem(opts.Directory), file) {
if handleRequest(resp, req, assetFS, subPath) {
return
}
@@ -85,13 +65,13 @@ func parseAcceptEncoding(val string) container.Set[string] {
// setWellKnownContentType will set the Content-Type if the file is a well-known type.
// See the comments of detectWellKnownMimeType
func setWellKnownContentType(w http.ResponseWriter, file string) {
mimeType := detectWellKnownMimeType(filepath.Ext(file))
mimeType := detectWellKnownMimeType(path.Ext(file))
if mimeType != "" {
w.Header().Set("Content-Type", mimeType)
}
}
func (opts *Options) handle(w http.ResponseWriter, req *http.Request, fs http.FileSystem, file string) bool {
func handleRequest(w http.ResponseWriter, req *http.Request, fs http.FileSystem, file string) bool {
// actually, fs (http.FileSystem) is designed to be a safe interface, relative paths won't bypass its parent directory, it's also fine to do a clean here
f, err := fs.Open(util.PathJoinRelX(file))
if err != nil {
@@ -121,8 +101,34 @@ func (opts *Options) handle(w http.ResponseWriter, req *http.Request, fs http.Fi
return true
}
setWellKnownContentType(w, file)
serveContent(w, req, fi, fi.ModTime(), f)
return true
}
type GzipBytesProvider interface {
GzipBytes() []byte
}
// serveContent serve http content
func serveContent(w http.ResponseWriter, req *http.Request, fi os.FileInfo, modtime time.Time, content io.ReadSeeker) {
setWellKnownContentType(w, fi.Name())
encodings := parseAcceptEncoding(req.Header.Get("Accept-Encoding"))
if encodings.Contains("gzip") {
// try to provide gzip content directly from bindata (provided by vfsgen۰CompressedFileInfo)
if compressed, ok := fi.(GzipBytesProvider); ok {
rdGzip := bytes.NewReader(compressed.GzipBytes())
// all gzipped static files (from bindata) are managed by Gitea, so we can make sure every file has the correct ext name
// then we can get the correct Content-Type, we do not need to do http.DetectContentType on the decompressed data
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Content-Encoding", "gzip")
http.ServeContent(w, req, fi.Name(), modtime, rdGzip)
return
}
}
http.ServeContent(w, req, fi.Name(), modtime, content)
return
}
+4 -11
View File
@@ -6,17 +6,10 @@
package public
import (
"io"
"net/http"
"os"
"time"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/setting"
)
func fileSystem(dir string) http.FileSystem {
return http.Dir(dir)
}
// serveContent serve http content
func serveContent(w http.ResponseWriter, req *http.Request, fi os.FileInfo, modtime time.Time, content io.ReadSeeker) {
http.ServeContent(w, req, fi.Name(), modtime, content)
func BuiltinAssets() *assetfs.Layer {
return assetfs.Local("builtin(static)", setting.StaticRootPath, "public")
}
+5 -61
View File
@@ -6,75 +6,19 @@
package public
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"time"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/timeutil"
)
var _ GzipBytesProvider = (*vfsgen۰CompressedFileInfo)(nil)
// GlobalModTime provide a global mod time for embedded asset files
func GlobalModTime(filename string) time.Time {
return timeutil.GetExecutableModTime()
}
func fileSystem(dir string) http.FileSystem {
return Assets
}
func Asset(name string) ([]byte, error) {
f, err := Assets.Open("/" + name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
func AssetNames() []string {
realFS := Assets.(vfsgen۰FS)
results := make([]string, 0, len(realFS))
for k := range realFS {
results = append(results, k[1:])
}
return results
}
func AssetIsDir(name string) (bool, error) {
if f, err := Assets.Open("/" + name); err != nil {
return false, err
} else {
defer f.Close()
if fi, err := f.Stat(); err != nil {
return false, err
} else {
return fi.IsDir(), nil
}
}
}
// serveContent serve http content
func serveContent(w http.ResponseWriter, req *http.Request, fi os.FileInfo, modtime time.Time, content io.ReadSeeker) {
encodings := parseAcceptEncoding(req.Header.Get("Accept-Encoding"))
if encodings.Contains("gzip") {
if cf, ok := fi.(*vfsgen۰CompressedFileInfo); ok {
rdGzip := bytes.NewReader(cf.GzipBytes())
// all static files are managed by Gitea, so we can make sure every file has the correct ext name
// then we can get the correct Content-Type, we do not need to do http.DetectContentType on the decompressed data
mimeType := detectWellKnownMimeType(filepath.Ext(fi.Name()))
if mimeType == "" {
mimeType = "application/octet-stream"
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Encoding", "gzip")
http.ServeContent(w, req, fi.Name(), modtime, rdGzip)
return
}
}
http.ServeContent(w, req, fi.Name(), modtime, content)
return
func BuiltinAssets() *assetfs.Layer {
return assetfs.Bindata("builtin(bindata)", Assets)
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/nosql"
"github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"
)
// RedisQueueType is the type for redis queue
+1 -1
View File
@@ -6,7 +6,7 @@ package queue
import (
"context"
"github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"
)
// RedisUniqueQueueType is the type for redis queue
+1 -2
View File
@@ -23,7 +23,6 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
@@ -191,7 +190,7 @@ func CreateRepository(doer, u *user_model.User, opts CreateRepoOptions) (*repo_m
// Check if label template exist
if len(opts.IssueLabels) > 0 {
if _, err := label.GetTemplateFile(opts.IssueLabels); err != nil {
if _, err := LoadTemplateLabelsByDisplayName(opts.IssueLabels); err != nil {
return nil, err
}
}
+2 -2
View File
@@ -21,11 +21,11 @@ func CanUserDelete(repo *repo_model.Repository, user *user_model.User) (bool, er
}
if repo.Owner.IsOrganization() {
isOwner, err := organization.OrgFromUser(repo.Owner).IsOwnedBy(user.ID)
isAdmin, err := organization.OrgFromUser(repo.Owner).IsOrgAdmin(user.ID)
if err != nil {
return false, err
}
return isOwner, nil
return isAdmin, nil
}
return false, nil
+71 -50
View File
@@ -8,7 +8,6 @@ import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
@@ -27,6 +26,11 @@ import (
asymkey_service "code.gitea.io/gitea/services/asymkey"
)
type OptionFile struct {
DisplayName string
Description string
}
var (
// Gitignores contains the gitiginore files
Gitignores []string
@@ -37,65 +41,73 @@ var (
// Readmes contains the readme files
Readmes []string
// LabelTemplates contains the label template files and the list of labels for each file
LabelTemplates map[string]string
// LabelTemplateFiles contains the label template files, each item has its DisplayName and Description
LabelTemplateFiles []OptionFile
labelTemplateFileMap = map[string]string{} // DisplayName => FileName mapping
)
// LoadRepoConfig loads the repository config
func LoadRepoConfig() {
// Load .gitignore and license files and readme templates.
types := []string{"gitignore", "license", "readme", "label"}
typeFiles := make([][]string, 4)
for i, t := range types {
files, err := options.Dir(t)
if err != nil {
log.Fatal("Failed to get %s files: %v", t, err)
}
if t == "label" {
for i, f := range files {
ext := strings.ToLower(filepath.Ext(f))
if ext == ".yaml" || ext == ".yml" {
files[i] = f[:len(f)-len(ext)]
}
}
}
customPath := path.Join(setting.CustomPath, "options", t)
isDir, err := util.IsDir(customPath)
if err != nil {
log.Fatal("Failed to get custom %s files: %v", t, err)
}
if isDir {
customFiles, err := util.StatDir(customPath)
if err != nil {
log.Fatal("Failed to get custom %s files: %v", t, err)
}
type optionFileList struct {
all []string // all files provided by bindata & custom-path. Sorted.
custom []string // custom files provided by custom-path. Non-sorted, internal use only.
}
for _, f := range customFiles {
if !util.SliceContainsString(files, f, true) {
files = append(files, f)
}
// mergeCustomLabelFiles merges the custom label files. Always use the file's main name (DisplayName) as the key to de-duplicate.
func mergeCustomLabelFiles(fl optionFileList) []string {
exts := map[string]int{"": 0, ".yml": 1, ".yaml": 2} // "yaml" file has the highest priority to be used.
m := map[string]string{}
merge := func(list []string) {
sort.Slice(list, func(i, j int) bool { return exts[filepath.Ext(list[i])] < exts[filepath.Ext(list[j])] })
for _, f := range list {
m[strings.TrimSuffix(f, filepath.Ext(f))] = f
}
}
merge(fl.all)
merge(fl.custom)
files := make([]string, 0, len(m))
for _, f := range m {
files = append(files, f)
}
sort.Strings(files)
return files
}
// LoadRepoConfig loads the repository config
func LoadRepoConfig() error {
types := []string{"gitignore", "license", "readme", "label"} // option file directories
typeFiles := make([]optionFileList, len(types))
for i, t := range types {
var err error
if typeFiles[i].all, err = options.AssetFS().ListFiles(t, true); err != nil {
return fmt.Errorf("failed to list %s files: %w", t, err)
}
sort.Strings(typeFiles[i].all)
customPath := filepath.Join(setting.CustomPath, "options", t)
if isDir, err := util.IsDir(customPath); err != nil {
return fmt.Errorf("failed to check custom %s dir: %w", t, err)
} else if isDir {
if typeFiles[i].custom, err = util.StatDir(customPath); err != nil {
return fmt.Errorf("failed to list custom %s files: %w", t, err)
}
}
typeFiles[i] = files
}
Gitignores = typeFiles[0]
Licenses = typeFiles[1]
Readmes = typeFiles[2]
LabelTemplatesFiles := typeFiles[3]
sort.Strings(Gitignores)
sort.Strings(Licenses)
sort.Strings(Readmes)
sort.Strings(LabelTemplatesFiles)
Gitignores = typeFiles[0].all
Licenses = typeFiles[1].all
Readmes = typeFiles[2].all
// Load label templates
LabelTemplates = make(map[string]string)
for _, templateFile := range LabelTemplatesFiles {
labels, err := label.LoadFormatted(templateFile)
LabelTemplateFiles = nil
labelTemplateFileMap = map[string]string{}
for _, file := range mergeCustomLabelFiles(typeFiles[3]) {
description, err := label.LoadTemplateDescription(file)
if err != nil {
log.Error("Failed to load labels: %v", err)
return fmt.Errorf("failed to load labels: %w", err)
}
LabelTemplates[templateFile] = labels
displayName := strings.TrimSuffix(file, filepath.Ext(file))
labelTemplateFileMap[displayName] = file
LabelTemplateFiles = append(LabelTemplateFiles, OptionFile{DisplayName: displayName, Description: description})
}
// Filter out invalid names and promote preferred licenses.
@@ -111,6 +123,7 @@ func LoadRepoConfig() {
}
}
Licenses = sortedLicenses
return nil
}
func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir, repoPath string, opts CreateRepoOptions) error {
@@ -344,7 +357,7 @@ func initRepository(ctx context.Context, repoPath string, u *user_model.User, re
// InitializeLabels adds a label set to a repository using a template
func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg bool) error {
list, err := label.GetTemplateFile(labelTemplate)
list, err := LoadTemplateLabelsByDisplayName(labelTemplate)
if err != nil {
return err
}
@@ -370,3 +383,11 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg
}
return nil
}
// LoadTemplateLabelsByDisplayName loads a label template by its display name
func LoadTemplateLabelsByDisplayName(displayName string) ([]*label.Label, error) {
if fileName, ok := labelTemplateFileMap[displayName]; ok {
return label.LoadTemplateFile(fileName)
}
return nil, label.ErrTemplateLoad{TemplateFile: displayName, OriginalError: fmt.Errorf("label template %q not found", displayName)}
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMergeCustomLabels(t *testing.T) {
files := mergeCustomLabelFiles(optionFileList{
all: []string{"a", "a.yaml", "a.yml"},
custom: nil,
})
assert.EqualValues(t, []string{"a.yaml"}, files, "yaml file should win")
files = mergeCustomLabelFiles(optionFileList{
all: []string{"a", "a.yaml"},
custom: []string{"a"},
})
assert.EqualValues(t, []string{"a"}, files, "custom file should win")
files = mergeCustomLabelFiles(optionFileList{
all: []string{"a", "a.yml", "a.yaml"},
custom: []string{"a", "a.yml"},
})
assert.EqualValues(t, []string{"a.yml"}, files, "custom yml file should win if no yaml")
}
+12 -7
View File
@@ -26,7 +26,7 @@ import (
"code.gitea.io/gitea/modules/nosql"
"gitea.com/go-chi/session"
"github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"
)
// RedisStore represents a redis session store implementation.
@@ -183,16 +183,21 @@ func (p *RedisProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err
}
}
if err = p.c.Rename(graceful.GetManager().HammerContext(), poldsid, psid).Err(); err != nil {
return nil, err
}
var kv map[interface{}]interface{}
kvs, err := p.c.Get(graceful.GetManager().HammerContext(), psid).Result()
// do not use Rename here, because the old sid and new sid may be in different redis cluster slot.
kvs, err := p.c.Get(graceful.GetManager().HammerContext(), poldsid).Result()
if err != nil {
return nil, err
}
if err = p.c.Del(graceful.GetManager().HammerContext(), poldsid).Err(); err != nil {
return nil, err
}
if err = p.c.Set(graceful.GetManager().HammerContext(), psid, kvs, p.duration).Err(); err != nil {
return nil, err
}
var kv map[interface{}]interface{}
if len(kvs) == 0 {
kv = make(map[interface{}]interface{})
} else {
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !bindata
package setting
const HasBuiltinBindata = false
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build bindata
package setting
const HasBuiltinBindata = true
+15
View File
@@ -4,6 +4,7 @@
package setting
import (
"net/url"
"path/filepath"
"strings"
"time"
@@ -18,6 +19,7 @@ var Indexer = struct {
IssueType string
IssuePath string
IssueConnStr string
IssueConnAuth string
IssueIndexerName string
StartupTimeout time.Duration
@@ -34,6 +36,7 @@ var Indexer = struct {
IssueType: "bleve",
IssuePath: "indexers/issues.bleve",
IssueConnStr: "",
IssueConnAuth: "",
IssueIndexerName: "gitea_issues",
RepoIndexerEnabled: false,
@@ -53,6 +56,18 @@ func loadIndexerFrom(rootCfg ConfigProvider) {
Indexer.IssuePath = filepath.ToSlash(filepath.Join(AppWorkPath, Indexer.IssuePath))
}
Indexer.IssueConnStr = sec.Key("ISSUE_INDEXER_CONN_STR").MustString(Indexer.IssueConnStr)
if Indexer.IssueType == "meilisearch" {
u, err := url.Parse(Indexer.IssueConnStr)
if err != nil {
log.Warn("Failed to parse ISSUE_INDEXER_CONN_STR: %v", err)
u = &url.URL{}
}
Indexer.IssueConnAuth, _ = u.User.Password()
u.User = nil
Indexer.IssueConnStr = u.String()
}
Indexer.IssueIndexerName = sec.Key("ISSUE_INDEXER_NAME").MustString(Indexer.IssueIndexerName)
// The following settings are deprecated and can be overridden by settings in [queue] or [queue.issue_indexer]
+1 -1
View File
@@ -152,7 +152,7 @@ func loadLogFrom(rootCfg ConfigProvider) {
Log.EnableSSHLog = sec.Key("ENABLE_SSH_LOG").MustBool(false)
Log.EnableAccessLog = sec.Key("ENABLE_ACCESS_LOG").MustBool(false)
Log.AccessLogTemplate = sec.Key("ACCESS_LOG_TEMPLATE").MustString(
`{{.Ctx.RemoteAddr}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}\" \"{{.Ctx.Req.UserAgent}}"`,
`{{.Ctx.RemoteHost}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}" "{{.Ctx.Req.UserAgent}}"`,
)
Log.RequestIDHeaders = sec.Key("REQUEST_ID_HEADERS").Strings(",")
// the `MustString` updates the default value, and `log.ACCESS` is used by `generateNamedLogger("access")` later
+2 -2
View File
@@ -21,7 +21,7 @@ var SessionConfig = struct {
ProviderConfig string
// Cookie name to save session ID. Default is "MacaronSession".
CookieName string
// Cookie path to store. Default is "/".
// Cookie path to store. Default is "/". HINT: there was a bug, the old value doesn't have trailing slash, and could be empty "".
CookiePath string
// GC interval time in seconds. Default is 3600.
Gclifetime int64
@@ -49,7 +49,7 @@ func loadSessionFrom(rootCfg ConfigProvider) {
SessionConfig.ProviderConfig = path.Join(AppWorkPath, SessionConfig.ProviderConfig)
}
SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea")
SessionConfig.CookiePath = AppSubURL
SessionConfig.CookiePath = AppSubURL + "/" // there was a bug, old code only set CookePath=AppSubURL, no trailing slash
SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(false)
SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400)
SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_TIME").MustInt64(86400)
+2 -2
View File
@@ -58,7 +58,7 @@ var SSH = struct {
ServerCiphers: []string{"chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"},
ServerKeyExchanges: []string{"curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"},
ServerMACs: []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256", "hmac-sha1"},
KeygenPath: "ssh-keygen",
KeygenPath: "",
MinimumKeySizeCheck: true,
MinimumKeySizes: map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 2047},
ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gogs.rsa"},
@@ -134,7 +134,7 @@ func loadSSHFrom(rootCfg ConfigProvider) {
}
}
SSH.KeygenPath = sec.Key("SSH_KEYGEN_PATH").MustString("ssh-keygen")
SSH.KeygenPath = sec.Key("SSH_KEYGEN_PATH").String()
SSH.Port = sec.Key("SSH_PORT").MustInt(22)
SSH.ListenPort = sec.Key("SSH_LISTEN_PORT").MustInt(SSH.Port)
SSH.UseProxyProtocol = sec.Key("SSH_SERVER_USE_PROXY_PROTOCOL").MustBool(false)
+1
View File
@@ -42,6 +42,7 @@ func getStorage(rootCfg ConfigProvider, name, typ string, targetSec *ini.Section
sec.Key("MINIO_LOCATION").MustString("us-east-1")
sec.Key("MINIO_USE_SSL").MustBool(false)
sec.Key("MINIO_INSECURE_SKIP_VERIFY").MustBool(false)
sec.Key("MINIO_CHECKSUM_ALGORITHM").MustString("default")
if targetSec == nil {
targetSec, _ = rootCfg.NewSection(name)
+3
View File
@@ -139,6 +139,9 @@ func loadUIFrom(rootCfg ConfigProvider) {
UI.DefaultShowFullName = sec.Key("DEFAULT_SHOW_FULL_NAME").MustBool(false)
UI.SearchRepoDescription = sec.Key("SEARCH_REPO_DESCRIPTION").MustBool(true)
UI.UseServiceWorker = sec.Key("USE_SERVICE_WORKER").MustBool(false)
// OnlyShowRelevantRepos=false is important for many private/enterprise instances,
// because many private repositories do not have "description/topic", users just want to search by their names.
UI.OnlyShowRelevantRepos = sec.Key("ONLY_SHOW_RELEVANT_REPOS").MustBool(false)
UI.ReactionsLookup = make(container.Set[string])
+18 -3
View File
@@ -6,6 +6,7 @@ package storage
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"
@@ -53,10 +54,12 @@ type MinioStorageConfig struct {
BasePath string `ini:"MINIO_BASE_PATH"`
UseSSL bool `ini:"MINIO_USE_SSL"`
InsecureSkipVerify bool `ini:"MINIO_INSECURE_SKIP_VERIFY"`
ChecksumAlgorithm string `ini:"MINIO_CHECKSUM_ALGORITHM"`
}
// MinioStorage returns a minio bucket storage
type MinioStorage struct {
cfg *MinioStorageConfig
ctx context.Context
client *minio.Client
bucket string
@@ -91,6 +94,10 @@ func NewMinioStorage(ctx context.Context, cfg interface{}) (ObjectStorage, error
}
config := configInterface.(MinioStorageConfig)
if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
}
log.Info("Creating Minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
minioClient, err := minio.New(config.Endpoint, &minio.Options{
@@ -113,6 +120,7 @@ func NewMinioStorage(ctx context.Context, cfg interface{}) (ObjectStorage, error
}
return &MinioStorage{
cfg: &config,
ctx: ctx,
client: minioClient,
bucket: config.Bucket,
@@ -124,7 +132,7 @@ func (m *MinioStorage) buildMinioPath(p string) string {
return util.PathJoinRelX(m.basePath, p)
}
// Open open a file
// Open opens a file
func (m *MinioStorage) Open(path string) (Object, error) {
opts := minio.GetObjectOptions{}
object, err := m.client.GetObject(m.ctx, m.bucket, m.buildMinioPath(path), opts)
@@ -134,7 +142,7 @@ func (m *MinioStorage) Open(path string) (Object, error) {
return &minioObject{object}, nil
}
// Save save a file to minio
// Save saves a file to minio
func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error) {
uploadInfo, err := m.client.PutObject(
m.ctx,
@@ -142,7 +150,14 @@ func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error)
m.buildMinioPath(path),
r,
size,
minio.PutObjectOptions{ContentType: "application/octet-stream"},
minio.PutObjectOptions{
ContentType: "application/octet-stream",
// some storages like:
// * https://developers.cloudflare.com/r2/api/s3/api/
// * https://www.backblaze.com/b2/docs/s3_compatible_api.html
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
},
)
if err != nil {
return 0, convertMinioErr(err)
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
import "time"
type Activity struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"` // Receiver user
OpType string `json:"op_type"`
ActUserID int64 `json:"act_user_id"`
ActUser *User `json:"act_user"`
RepoID int64 `json:"repo_id"`
Repo *Repository `json:"repo"`
CommentID int64 `json:"comment_id"`
Comment *Comment `json:"comment"`
RefName string `json:"ref_name"`
IsPrivate bool `json:"is_private"`
Content string `json:"content"`
Created time.Time `json:"created"`
}
+24
View File
@@ -190,6 +190,22 @@ func (l *IssueTemplateLabels) UnmarshalYAML(value *yaml.Node) error {
return fmt.Errorf("line %d: cannot unmarshal %s into IssueTemplateLabels", value.Line, value.ShortTag())
}
type IssueConfigContactLink struct {
Name string `json:"name" yaml:"name"`
URL string `json:"url" yaml:"url"`
About string `json:"about" yaml:"about"`
}
type IssueConfig struct {
BlankIssuesEnabled bool `json:"blank_issues_enabled" yaml:"blank_issues_enabled"`
ContactLinks []IssueConfigContactLink `json:"contact_links" yaml:"contact_links"`
}
type IssueConfigValidation struct {
Valid bool `json:"valid"`
Message string `json:"message"`
}
// IssueTemplateType defines issue template type
type IssueTemplateType string
@@ -211,3 +227,11 @@ func (it IssueTemplate) Type() IssueTemplateType {
}
return ""
}
// IssueMeta basic issue information
// swagger:model
type IssueMeta struct {
Index int64 `json:"index"`
Owner string `json:"owner"`
Name string `json:"repo"`
}
-30
View File
@@ -1,30 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build bindata
package svg
import (
"path/filepath"
"code.gitea.io/gitea/modules/public"
)
// Discover returns a map of discovered SVG icons in bindata
func Discover() map[string]string {
svgs := make(map[string]string)
for _, file := range public.AssetNames() {
matched, _ := filepath.Match("img/svg/*.svg", file)
if matched {
content, err := public.Asset(file)
if err == nil {
filename := filepath.Base(file)
svgs[filename[:len(filename)-4]] = string(content)
}
}
}
return svgs
}
-29
View File
@@ -1,29 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !bindata
package svg
import (
"os"
"path/filepath"
"code.gitea.io/gitea/modules/setting"
)
// Discover returns a map of discovered SVG icons in the file system
func Discover() map[string]string {
svgs := make(map[string]string)
files, _ := filepath.Glob(filepath.Join(setting.StaticRootPath, "public", "img", "svg", "*.svg"))
for _, file := range files {
content, err := os.ReadFile(file)
if err == nil {
filename := filepath.Base(file)
svgs[filename[:len(filename)-4]] = string(content)
}
}
return svgs
}
+22 -7
View File
@@ -6,15 +6,18 @@ package svg
import (
"fmt"
"html/template"
"path"
"regexp"
"strings"
"code.gitea.io/gitea/modules/html"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
)
var (
// SVGs contains discovered SVGs
SVGs map[string]string
SVGs = map[string]string{}
widthRe = regexp.MustCompile(`width="[0-9]+?"`)
heightRe = regexp.MustCompile(`height="[0-9]+?"`)
@@ -23,17 +26,29 @@ var (
const defaultSize = 16
// Init discovers SVGs and populates the `SVGs` variable
func Init() {
SVGs = Discover()
func Init() error {
files, err := public.AssetFS().ListFiles("img/svg")
if err != nil {
return err
}
// Remove `xmlns` because inline SVG does not need it
r := regexp.MustCompile(`(<svg\b[^>]*?)\s+xmlns="[^"]*"`)
for name, svg := range SVGs {
SVGs[name] = r.ReplaceAllString(svg, "$1")
reXmlns := regexp.MustCompile(`(<svg\b[^>]*?)\s+xmlns="[^"]*"`)
for _, file := range files {
if path.Ext(file) != ".svg" {
continue
}
bs, err := public.AssetFS().ReadFile("img/svg", file)
if err != nil {
log.Error("Failed to read SVG file %s: %v", file, err)
} else {
SVGs[file[:len(file)-4]] = reXmlns.ReplaceAllString(string(bs), "$1")
}
}
return nil
}
// Render render icons - arguments icon name (string), size (int), class (string)
// RenderHTML renders icons - arguments icon name (string), size (int), class (string)
func RenderHTML(icon string, others ...interface{}) template.HTML {
size, class := html.ParseSizeAndClass(defaultSize, "", others...)
+19 -74
View File
@@ -4,14 +4,10 @@
package templates
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
@@ -47,81 +43,30 @@ func BaseVars() Vars {
}
}
func getDirTemplateAssetNames(dir string) []string {
return getDirAssetNames(dir, false)
func AssetFS() *assetfs.LayeredFS {
return assetfs.Layered(CustomAssets(), BuiltinAssets())
}
func getDirAssetNames(dir string, mailer bool) []string {
var tmpls []string
func CustomAssets() *assetfs.Layer {
return assetfs.Local("custom", setting.CustomPath, "templates")
}
if mailer {
dir += filepath.Join(dir, "mail")
}
f, err := os.Stat(dir)
func ListWebTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
files, err := assets.ListAllFiles(".", true)
if err != nil {
if os.IsNotExist(err) {
return tmpls
}
log.Warn("Unable to check if templates dir %s is a directory. Error: %v", dir, err)
return tmpls
}
if !f.IsDir() {
log.Warn("Templates dir %s is a not directory.", dir)
return tmpls
return nil, err
}
return util.SliceRemoveAllFunc(files, func(file string) bool {
return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
}), nil
}
files, err := util.StatDir(dir)
func ListMailTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
files, err := assets.ListAllFiles(".", true)
if err != nil {
log.Warn("Failed to read %s templates dir. %v", dir, err)
return tmpls
return nil, err
}
prefix := "templates/"
if mailer {
prefix += "mail/"
}
for _, filePath := range files {
if !mailer && strings.HasPrefix(filePath, "mail/") {
continue
}
if !strings.HasSuffix(filePath, ".tmpl") {
continue
}
tmpls = append(tmpls, prefix+filePath)
}
return tmpls
}
func walkAssetDir(root string, skipMail bool, callback func(path, name string, d fs.DirEntry, err error) error) error {
mailRoot := filepath.Join(root, "mail")
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
name := path[len(root):]
if len(name) > 0 && name[0] == '/' {
name = name[1:]
}
if err != nil {
if os.IsNotExist(err) {
return callback(path, name, d, err)
}
return err
}
if skipMail && path == mailRoot && d.IsDir() {
return fs.SkipDir
}
if util.CommonSkip(d.Name()) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if strings.HasSuffix(d.Name(), ".tmpl") || d.IsDir() {
return callback(path, name, d, err)
}
return nil
}); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to get files for template assets in %s: %w", root, err)
}
return nil
return util.SliceRemoveAllFunc(files, func(file string) bool {
return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
}), nil
}
+3 -76
View File
@@ -6,83 +6,10 @@
package templates
import (
"html/template"
"io/fs"
"os"
"path/filepath"
texttmpl "text/template"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/setting"
)
var (
subjectTemplates = texttmpl.New("")
bodyTemplates = template.New("")
)
// GetAsset returns asset content via name
func GetAsset(name string) ([]byte, error) {
bs, err := os.ReadFile(filepath.Join(setting.CustomPath, name))
if err != nil && !os.IsNotExist(err) {
return nil, err
} else if err == nil {
return bs, nil
}
return os.ReadFile(filepath.Join(setting.StaticRootPath, name))
}
// GetAssetFilename returns the filename of the provided asset
func GetAssetFilename(name string) (string, error) {
filename := filepath.Join(setting.CustomPath, name)
_, err := os.Stat(filename)
if err != nil && !os.IsNotExist(err) {
return filename, err
} else if err == nil {
return filename, nil
}
filename = filepath.Join(setting.StaticRootPath, name)
_, err = os.Stat(filename)
return filename, err
}
// walkTemplateFiles calls a callback for each template asset
func walkTemplateFiles(callback func(path, name string, d fs.DirEntry, err error) error) error {
if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
return err
}
if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// GetTemplateAssetNames returns list of template names
func GetTemplateAssetNames() []string {
tmpls := getDirTemplateAssetNames(filepath.Join(setting.CustomPath, "templates"))
tmpls2 := getDirTemplateAssetNames(filepath.Join(setting.StaticRootPath, "templates"))
return append(tmpls, tmpls2...)
}
func walkMailerTemplates(callback func(path, name string, d fs.DirEntry, err error) error) error {
if err := walkAssetDir(filepath.Join(setting.StaticRootPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
return err
}
if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// BuiltinAsset will read the provided asset from the embedded assets
// (This always returns os.ErrNotExist)
func BuiltinAsset(name string) ([]byte, error) {
return nil, os.ErrNotExist
}
// BuiltinAssetNames returns the names of the embedded assets
// (This always returns nil)
func BuiltinAssetNames() []string {
return nil
func BuiltinAssets() *assetfs.Layer {
return assetfs.Local("builtin(static)", setting.StaticRootPath, "templates")
}
+344
View File
@@ -0,0 +1,344 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package eval
import (
"fmt"
"strconv"
"strings"
"code.gitea.io/gitea/modules/util"
)
type Num struct {
Value any // int64 or float64, nil on error
}
var opPrecedence = map[string]int{
// "(": 1, this is for low precedence like function calls, they are handled separately
"or": 2,
"and": 3,
"not": 4,
"==": 5, "!=": 5, "<": 5, "<=": 5, ">": 5, ">=": 5,
"+": 6, "-": 6,
"*": 7, "/": 7,
}
type stack[T any] struct {
name string
elems []T
}
func (s *stack[T]) push(t T) {
s.elems = append(s.elems, t)
}
func (s *stack[T]) pop() T {
if len(s.elems) == 0 {
panic(s.name + " stack is empty")
}
t := s.elems[len(s.elems)-1]
s.elems = s.elems[:len(s.elems)-1]
return t
}
func (s *stack[T]) peek() T {
if len(s.elems) == 0 {
panic(s.name + " stack is empty")
}
return s.elems[len(s.elems)-1]
}
type operator string
type eval struct {
stackNum stack[Num]
stackOp stack[operator]
funcMap map[string]func([]Num) Num
}
func newEval() *eval {
e := &eval{}
e.stackNum.name = "num"
e.stackOp.name = "op"
return e
}
func toNum(v any) (Num, error) {
switch v := v.(type) {
case string:
if strings.Contains(v, ".") {
n, err := strconv.ParseFloat(v, 64)
if err != nil {
return Num{n}, err
}
return Num{n}, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return Num{n}, err
}
return Num{n}, nil
case float32, float64:
n, _ := util.ToFloat64(v)
return Num{n}, nil
default:
n, err := util.ToInt64(v)
if err != nil {
return Num{n}, err
}
return Num{n}, nil
}
}
func truth(b bool) int64 {
if b {
return int64(1)
}
return int64(0)
}
func applyOp2Generic[T int64 | float64](op operator, n1, n2 T) Num {
switch op {
case "+":
return Num{n1 + n2}
case "-":
return Num{n1 - n2}
case "*":
return Num{n1 * n2}
case "/":
return Num{n1 / n2}
case "==":
return Num{truth(n1 == n2)}
case "!=":
return Num{truth(n1 != n2)}
case "<":
return Num{truth(n1 < n2)}
case "<=":
return Num{truth(n1 <= n2)}
case ">":
return Num{truth(n1 > n2)}
case ">=":
return Num{truth(n1 >= n2)}
case "and":
t1, _ := util.ToFloat64(n1)
t2, _ := util.ToFloat64(n2)
return Num{truth(t1 != 0 && t2 != 0)}
case "or":
t1, _ := util.ToFloat64(n1)
t2, _ := util.ToFloat64(n2)
return Num{truth(t1 != 0 || t2 != 0)}
}
panic("unknown operator: " + string(op))
}
func applyOp2(op operator, n1, n2 Num) Num {
float := false
if _, ok := n1.Value.(float64); ok {
float = true
} else if _, ok = n2.Value.(float64); ok {
float = true
}
if float {
f1, _ := util.ToFloat64(n1.Value)
f2, _ := util.ToFloat64(n2.Value)
return applyOp2Generic(op, f1, f2)
}
return applyOp2Generic(op, n1.Value.(int64), n2.Value.(int64))
}
func toOp(v any) (operator, error) {
if v, ok := v.(string); ok {
return operator(v), nil
}
return "", fmt.Errorf(`unsupported token type "%T"`, v)
}
func (op operator) hasOpenBracket() bool {
return strings.HasSuffix(string(op), "(") // it's used to support functions like "sum("
}
func (op operator) isComma() bool {
return op == ","
}
func (op operator) isCloseBracket() bool {
return op == ")"
}
type ExprError struct {
msg string
tokens []any
err error
}
func (err ExprError) Error() string {
sb := strings.Builder{}
sb.WriteString(err.msg)
sb.WriteString(" [ ")
for _, token := range err.tokens {
_, _ = fmt.Fprintf(&sb, `"%v" `, token)
}
sb.WriteString("]")
if err.err != nil {
sb.WriteString(": ")
sb.WriteString(err.err.Error())
}
return sb.String()
}
func (err ExprError) Unwrap() error {
return err.err
}
func (e *eval) applyOp() {
op := e.stackOp.pop()
if op == "not" {
num := e.stackNum.pop()
i, _ := util.ToInt64(num.Value)
e.stackNum.push(Num{truth(i == 0)})
} else if op.hasOpenBracket() || op.isCloseBracket() || op.isComma() {
panic(fmt.Sprintf("incomplete sub-expression with operator %q", op))
} else {
num2 := e.stackNum.pop()
num1 := e.stackNum.pop()
e.stackNum.push(applyOp2(op, num1, num2))
}
}
func (e *eval) exec(tokens ...any) (ret Num, err error) {
defer func() {
if r := recover(); r != nil {
rErr, ok := r.(error)
if !ok {
rErr = fmt.Errorf("%v", r)
}
err = ExprError{"invalid expression", tokens, rErr}
}
}()
for _, token := range tokens {
n, err := toNum(token)
if err == nil {
e.stackNum.push(n)
continue
}
op, err := toOp(token)
if err != nil {
return Num{}, ExprError{"invalid expression", tokens, err}
}
switch {
case op.hasOpenBracket():
e.stackOp.push(op)
case op.isCloseBracket(), op.isComma():
var stackTopOp operator
for len(e.stackOp.elems) > 0 {
stackTopOp = e.stackOp.peek()
if stackTopOp.hasOpenBracket() || stackTopOp.isComma() {
break
}
e.applyOp()
}
if op.isCloseBracket() {
nums := []Num{e.stackNum.pop()}
for !e.stackOp.peek().hasOpenBracket() {
stackTopOp = e.stackOp.pop()
if !stackTopOp.isComma() {
return Num{}, ExprError{"bracket doesn't match", tokens, nil}
}
nums = append(nums, e.stackNum.pop())
}
for i, j := 0, len(nums)-1; i < j; i, j = i+1, j-1 {
nums[i], nums[j] = nums[j], nums[i] // reverse nums slice, to get the right order for arguments
}
stackTopOp = e.stackOp.pop()
fn := string(stackTopOp[:len(stackTopOp)-1])
if fn == "" {
if len(nums) != 1 {
return Num{}, ExprError{"too many values in one bracket", tokens, nil}
}
e.stackNum.push(nums[0])
} else if f, ok := e.funcMap[fn]; ok {
e.stackNum.push(f(nums))
} else {
return Num{}, ExprError{"unknown function: " + fn, tokens, nil}
}
} else {
e.stackOp.push(op)
}
default:
for len(e.stackOp.elems) > 0 && len(e.stackNum.elems) > 0 {
stackTopOp := e.stackOp.peek()
if stackTopOp.hasOpenBracket() || stackTopOp.isComma() || precedence(stackTopOp, op) < 0 {
break
}
e.applyOp()
}
e.stackOp.push(op)
}
}
for len(e.stackOp.elems) > 0 && !e.stackOp.peek().isComma() {
e.applyOp()
}
if len(e.stackNum.elems) != 1 {
return Num{}, ExprError{fmt.Sprintf("expect 1 value as final result, but there are %d", len(e.stackNum.elems)), tokens, nil}
}
return e.stackNum.pop(), nil
}
func precedence(op1, op2 operator) int {
p1 := opPrecedence[string(op1)]
p2 := opPrecedence[string(op2)]
if p1 == 0 {
panic("unknown operator precedence: " + string(op1))
} else if p2 == 0 {
panic("unknown operator precedence: " + string(op2))
}
return p1 - p2
}
func castFloat64(nums []Num) bool {
hasFloat := false
for _, num := range nums {
if _, hasFloat = num.Value.(float64); hasFloat {
break
}
}
if hasFloat {
for i, num := range nums {
if _, ok := num.Value.(float64); !ok {
f, _ := util.ToFloat64(num.Value)
nums[i] = Num{f}
}
}
}
return hasFloat
}
func fnSum(nums []Num) Num {
if castFloat64(nums) {
var sum float64
for _, num := range nums {
sum += num.Value.(float64)
}
return Num{sum}
}
var sum int64
for _, num := range nums {
sum += num.Value.(int64)
}
return Num{sum}
}
// Expr evaluates the given expression tokens and returns the result.
// It supports the following operators: +, -, *, /, and, or, not, ==, !=, >, >=, <, <=.
// Non-zero values are treated as true, zero values are treated as false.
// If no error occurs, the result is either an int64 or a float64.
// If all numbers are integer, the result is an int64, otherwise if there is any float number, the result is a float64.
func Expr(tokens ...any) (Num, error) {
e := newEval()
e.funcMap = map[string]func([]Num) Num{"sum": fnSum}
return e.exec(tokens...)
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package eval
import (
"math"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func tokens(s string) (a []any) {
for _, v := range strings.Fields(s) {
a = append(a, v)
}
return a
}
func TestEval(t *testing.T) {
n, err := Expr(0, "/", 0.0)
assert.NoError(t, err)
assert.True(t, math.IsNaN(n.Value.(float64)))
_, err = Expr(nil)
assert.ErrorContains(t, err, "unsupported token type")
_, err = Expr([]string{})
assert.ErrorContains(t, err, "unsupported token type")
_, err = Expr(struct{}{})
assert.ErrorContains(t, err, "unsupported token type")
cases := []struct {
expr string
want any
}{
{"-1", int64(-1)},
{"1 + 2", int64(3)},
{"3 - 2 + 4", int64(5)},
{"1 + 2 * 3", int64(7)},
{"1 + ( 2 * 3 )", int64(7)},
{"( 1 + 2 ) * 3", int64(9)},
{"( 1 + 2.0 ) / 3", float64(1)},
{"sum( 1 , 2 , 3 , 4 )", int64(10)},
{"100 + sum( 1 , 2 + 3 , 0.0 ) / 2", float64(103)},
{"100 * 5 / ( 5 + 15 )", int64(25)},
{"9 == 5", int64(0)},
{"5 == 5", int64(1)},
{"9 != 5", int64(1)},
{"5 != 5", int64(0)},
{"9 > 5", int64(1)},
{"5 > 9", int64(0)},
{"5 >= 9", int64(0)},
{"9 >= 9", int64(1)},
{"9 < 5", int64(0)},
{"5 < 9", int64(1)},
{"9 <= 5", int64(0)},
{"5 <= 5", int64(1)},
{"1 and 2", int64(1)}, // Golang template definition: non-zero values are all truth
{"1 and 0", int64(0)},
{"0 and 0", int64(0)},
{"1 or 2", int64(1)},
{"1 or 0", int64(1)},
{"0 or 1", int64(1)},
{"0 or 0", int64(0)},
{"not 2 == 1", int64(1)},
{"not not ( 9 < 5 )", int64(0)},
}
for _, c := range cases {
n, err := Expr(tokens(c.expr)...)
if assert.NoError(t, err, "expr: %s", c.expr) {
assert.Equal(t, c.want, n.Value)
}
}
bads := []struct {
expr string
errMsg string
}{
{"0 / 0", "integer divide by zero"},
{"1 +", "num stack is empty"},
{"+ 1", "num stack is empty"},
{"( 1", "incomplete sub-expression"},
{"1 )", "op stack is empty"}, // can not find the corresponding open bracket after the stack becomes empty
{"1 , 2", "expect 1 value as final result"},
{"( 1 , 2 )", "too many values in one bracket"},
{"1 a 2", "unknown operator"},
}
for _, c := range bads {
_, err = Expr(tokens(c.expr)...)
assert.ErrorContains(t, err, c.errMsg, "expr: %s", c.expr)
}
}
+220 -495
View File
@@ -8,7 +8,6 @@ import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"html"
"html/template"
@@ -18,10 +17,7 @@ import (
"path/filepath"
"reflect"
"regexp"
"runtime"
"strconv"
"strings"
texttmpl "text/template"
"time"
"unicode"
@@ -44,6 +40,7 @@ import (
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/templates/eval"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/gitdiff"
@@ -57,12 +54,134 @@ var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}[\s]*$`)
// NewFuncMap returns functions for injecting to templates
func NewFuncMap() []template.FuncMap {
return []template.FuncMap{map[string]interface{}{
"GoVer": func() string {
return util.ToTitleCase(runtime.Version())
// -----------------------------------------------------------------
// html/template related functions
"dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
"Eval": Eval,
"Safe": Safe,
"Escape": html.EscapeString,
"QueryEscape": url.QueryEscape,
"JSEscape": template.JSEscapeString,
"Str2html": Str2html, // TODO: rename it to SanitizeHTML
"URLJoin": util.URLJoin,
"PathEscape": url.PathEscape,
"PathEscapeSegments": util.PathEscapeSegments,
// -----------------------------------------------------------------
// string / json
"Join": strings.Join,
"DotEscape": DotEscape,
"HasPrefix": strings.HasPrefix,
"EllipsisString": base.EllipsisString,
"Json": func(in interface{}) string {
out, err := json.Marshal(in)
if err != nil {
return ""
}
return string(out)
},
"UseHTTPS": func() bool {
return strings.HasPrefix(setting.AppURL, "https")
"JsonPrettyPrint": func(in string) string {
var out bytes.Buffer
err := json.Indent(&out, []byte(in), "", " ")
if err != nil {
return ""
}
return out.String()
},
// -----------------------------------------------------------------
// svg / avatar / icon
"svg": svg.RenderHTML,
"avatar": Avatar,
"avatarHTML": AvatarHTML,
"avatarByAction": AvatarByAction,
"avatarByEmail": AvatarByEmail,
"repoAvatar": RepoAvatar,
"EntryIcon": base.EntryIcon,
"MigrationIcon": MigrationIcon,
"ActionIcon": ActionIcon,
"SortArrow": func(normSort, revSort, urlSort string, isDefault bool) template.HTML {
// if needed
if len(normSort) == 0 || len(urlSort) == 0 {
return ""
}
if len(urlSort) == 0 && isDefault {
// if sort is sorted as default add arrow tho this table header
if isDefault {
return svg.RenderHTML("octicon-triangle-down", 16)
}
} else {
// if sort arg is in url test if it correlates with column header sort arguments
// the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev)
if urlSort == normSort {
// the table is sorted with this header normal
return svg.RenderHTML("octicon-triangle-up", 16)
} else if urlSort == revSort {
// the table is sorted with this header reverse
return svg.RenderHTML("octicon-triangle-down", 16)
}
}
// the table is NOT sorted with this header
return ""
},
// -----------------------------------------------------------------
// time / number / format
"FileSize": base.FileSize,
"LocaleNumber": LocaleNumber,
"CountFmt": base.FormatNumberSI,
"TimeSince": timeutil.TimeSince,
"TimeSinceUnix": timeutil.TimeSinceUnix,
"Sec2Time": util.SecToTime,
"DateFmtLong": func(t time.Time) string {
return t.Format(time.RFC3339)
},
"LoadTimes": func(startTime time.Time) string {
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
},
// -----------------------------------------------------------------
// slice
"containGeneric": func(arr, v interface{}) bool {
arrV := reflect.ValueOf(arr)
if arrV.Kind() == reflect.String && reflect.ValueOf(v).Kind() == reflect.String {
return strings.Contains(arr.(string), v.(string))
}
if arrV.Kind() == reflect.Slice {
for i := 0; i < arrV.Len(); i++ {
iV := arrV.Index(i)
if !iV.CanInterface() {
continue
}
if iV.Interface() == v {
return true
}
}
}
return false
},
"contain": func(s []int64, id int64) bool {
for i := 0; i < len(s); i++ {
if s[i] == id {
return true
}
}
return false
},
"Iterate": func(arg interface{}) (items []int64) {
count, _ := util.ToInt64(arg)
for i := int64(0); i < count; i++ {
items = append(items, i)
}
return items
},
// -----------------------------------------------------------------
// setting
"AppName": func() string {
return setting.AppName
},
@@ -82,10 +201,7 @@ func NewFuncMap() []template.FuncMap {
"AppVer": func() string {
return setting.AppVer
},
"AppBuiltWith": func() string {
return setting.AppBuiltWith
},
"AppDomain": func() string {
"AppDomain": func() string { // documented in mail-templates.md
return setting.Domain
},
"AssetVersion": func() string {
@@ -100,94 +216,12 @@ func NewFuncMap() []template.FuncMap {
"ShowFooterTemplateLoadTime": func() bool {
return setting.ShowFooterTemplateLoadTime
},
"LoadTimes": func(startTime time.Time) string {
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
},
"AllowedReactions": func() []string {
return setting.UI.Reactions
},
"CustomEmojis": func() map[string]string {
return setting.UI.CustomEmojisMap
},
"IsShowFullName": func() bool {
return setting.UI.DefaultShowFullName
},
"Safe": Safe,
"SafeJS": SafeJS,
"JSEscape": JSEscape,
"Str2html": Str2html,
"TimeSince": timeutil.TimeSince,
"TimeSinceUnix": timeutil.TimeSinceUnix,
"FileSize": base.FileSize,
"PrettyNumber": base.PrettyNumber,
"JsPrettyNumber": JsPrettyNumber,
"Subtract": base.Subtract,
"EntryIcon": base.EntryIcon,
"MigrationIcon": MigrationIcon,
"Add": func(a ...int) int {
sum := 0
for _, val := range a {
sum += val
}
return sum
},
"Mul": func(a ...int) int {
sum := 1
for _, val := range a {
sum *= val
}
return sum
},
"ActionIcon": ActionIcon,
"DateFmtLong": func(t time.Time) string {
return t.Format(time.RFC1123Z)
},
"DateFmtShort": func(t time.Time) string {
return t.Format("Jan 02, 2006")
},
"CountFmt": base.FormatNumberSI,
"SubStr": func(str string, start, length int) string {
if len(str) == 0 {
return ""
}
end := start + length
if length == -1 {
end = len(str)
}
if len(str) < end {
return str
}
return str[start:end]
},
"EllipsisString": base.EllipsisString,
"DiffTypeToStr": DiffTypeToStr,
"DiffLineTypeToStr": DiffLineTypeToStr,
"ShortSha": base.ShortSha,
"ActionContent2Commits": ActionContent2Commits,
"PathEscape": url.PathEscape,
"PathEscapeSegments": util.PathEscapeSegments,
"URLJoin": util.URLJoin,
"RenderCommitMessage": RenderCommitMessage,
"RenderCommitMessageLink": RenderCommitMessageLink,
"RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject,
"RenderCommitBody": RenderCommitBody,
"RenderCodeBlock": RenderCodeBlock,
"RenderIssueTitle": RenderIssueTitle,
"RenderEmoji": RenderEmoji,
"RenderEmojiPlain": emoji.ReplaceAliases,
"ReactionToEmoji": ReactionToEmoji,
"RenderNote": RenderNote,
"RenderMarkdownToHtml": func(ctx context.Context, input string) template.HTML {
output, err := markdown.RenderString(&markup.RenderContext{
Ctx: ctx,
URLPrefix: setting.AppSubURL,
}, input)
if err != nil {
log.Error("RenderString: %v", err)
}
return template.HTML(output)
},
"IsMultilineCommitMessage": IsMultilineCommitMessage,
"ThemeColorMetaTag": func() string {
return setting.UI.ThemeColorMetaTag
},
@@ -206,6 +240,82 @@ func NewFuncMap() []template.FuncMap {
"EnableTimetracking": func() bool {
return setting.Service.EnableTimetracking
},
"DisableGitHooks": func() bool {
return setting.DisableGitHooks
},
"DisableWebhooks": func() bool {
return setting.DisableWebhooks
},
"DisableImportLocal": func() bool {
return !setting.ImportLocalPaths
},
"DefaultTheme": func() string {
return setting.UI.DefaultTheme
},
"NotificationSettings": func() map[string]interface{} {
return map[string]interface{}{
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
"MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
"EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond),
}
},
"MermaidMaxSourceCharacters": func() int {
return setting.MermaidMaxSourceCharacters
},
// -----------------------------------------------------------------
// render
"RenderCommitMessage": RenderCommitMessage,
"RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject,
"RenderCommitBody": RenderCommitBody,
"RenderCodeBlock": RenderCodeBlock,
"RenderIssueTitle": RenderIssueTitle,
"RenderEmoji": RenderEmoji,
"RenderEmojiPlain": emoji.ReplaceAliases,
"ReactionToEmoji": ReactionToEmoji,
"RenderNote": RenderNote,
"RenderMarkdownToHtml": func(ctx context.Context, input string) template.HTML {
output, err := markdown.RenderString(&markup.RenderContext{
Ctx: ctx,
URLPrefix: setting.AppSubURL,
}, input)
if err != nil {
log.Error("RenderString: %v", err)
}
return template.HTML(output)
},
"RenderLabel": func(ctx context.Context, label *issues_model.Label) template.HTML {
return template.HTML(RenderLabel(ctx, label))
},
"RenderLabels": func(ctx context.Context, labels []*issues_model.Label, repoLink string) template.HTML {
htmlCode := `<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
}
htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ",
repoLink, label.ID, RenderLabel(ctx, label))
}
htmlCode += "</span>"
return template.HTML(htmlCode)
},
// -----------------------------------------------------------------
// misc
"DiffLineTypeToStr": DiffLineTypeToStr,
"ShortSha": base.ShortSha,
"ActionContent2Commits": ActionContent2Commits,
"IsMultilineCommitMessage": IsMultilineCommitMessage,
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
"MirrorRemoteAddress": mirrorRemoteAddress,
"ParseDeadline": func(deadline string) []string {
return strings.Split(deadline, "|")
},
"FilenameIsImage": func(filename string) bool {
mimeType := mime.TypeByExtension(filepath.Ext(filename))
return strings.HasPrefix(mimeType, "image/")
@@ -240,237 +350,6 @@ func NewFuncMap() []template.FuncMap {
}
return path
},
"DiffStatsWidth": func(adds, dels int) string {
return fmt.Sprintf("%f", float64(adds)/(float64(adds)+float64(dels))*100)
},
"Json": func(in interface{}) string {
out, err := json.Marshal(in)
if err != nil {
return ""
}
return string(out)
},
"JsonPrettyPrint": func(in string) string {
var out bytes.Buffer
err := json.Indent(&out, []byte(in), "", " ")
if err != nil {
return ""
}
return out.String()
},
"DisableGitHooks": func() bool {
return setting.DisableGitHooks
},
"DisableWebhooks": func() bool {
return setting.DisableWebhooks
},
"DisableImportLocal": func() bool {
return !setting.ImportLocalPaths
},
"Dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
"Printf": fmt.Sprintf,
"Escape": Escape,
"Sec2Time": util.SecToTime,
"ParseDeadline": func(deadline string) []string {
return strings.Split(deadline, "|")
},
"DefaultTheme": func() string {
return setting.UI.DefaultTheme
},
// pass key-value pairs to a partial template which receives them as a dict
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values) == 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{})
return util.MergeInto(dict, values...)
},
/* like dict but merge key-value pairs into the first dict and return it */
"mergeinto": func(root map[string]interface{}, values ...interface{}) (map[string]interface{}, error) {
if len(values) == 0 {
return nil, errors.New("invalid mergeinto call")
}
dict := make(map[string]interface{})
for key, value := range root {
dict[key] = value
}
return util.MergeInto(dict, values...)
},
"percentage": func(n int, values ...int) float32 {
sum := 0
for i := 0; i < len(values); i++ {
sum += values[i]
}
return float32(n) * 100 / float32(sum)
},
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
"MirrorRemoteAddress": mirrorRemoteAddress,
"NotificationSettings": func() map[string]interface{} {
return map[string]interface{}{
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
"MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
"EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond),
}
},
"containGeneric": func(arr, v interface{}) bool {
arrV := reflect.ValueOf(arr)
if arrV.Kind() == reflect.String && reflect.ValueOf(v).Kind() == reflect.String {
return strings.Contains(arr.(string), v.(string))
}
if arrV.Kind() == reflect.Slice {
for i := 0; i < arrV.Len(); i++ {
iV := arrV.Index(i)
if !iV.CanInterface() {
continue
}
if iV.Interface() == v {
return true
}
}
}
return false
},
"contain": func(s []int64, id int64) bool {
for i := 0; i < len(s); i++ {
if s[i] == id {
return true
}
}
return false
},
"svg": svg.RenderHTML,
"avatar": Avatar,
"avatarHTML": AvatarHTML,
"avatarByAction": AvatarByAction,
"avatarByEmail": AvatarByEmail,
"repoAvatar": RepoAvatar,
"SortArrow": func(normSort, revSort, urlSort string, isDefault bool) template.HTML {
// if needed
if len(normSort) == 0 || len(urlSort) == 0 {
return ""
}
if len(urlSort) == 0 && isDefault {
// if sort is sorted as default add arrow tho this table header
if isDefault {
return svg.RenderHTML("octicon-triangle-down", 16)
}
} else {
// if sort arg is in url test if it correlates with column header sort arguments
// the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev)
if urlSort == normSort {
// the table is sorted with this header normal
return svg.RenderHTML("octicon-triangle-up", 16)
} else if urlSort == revSort {
// the table is sorted with this header reverse
return svg.RenderHTML("octicon-triangle-down", 16)
}
}
// the table is NOT sorted with this header
return ""
},
"RenderLabel": func(ctx context.Context, label *issues_model.Label) template.HTML {
return template.HTML(RenderLabel(ctx, label))
},
"RenderLabels": func(ctx context.Context, labels []*issues_model.Label, repoLink string) template.HTML {
htmlCode := `<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
}
htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ",
repoLink, label.ID, RenderLabel(ctx, label))
}
htmlCode += "</span>"
return template.HTML(htmlCode)
},
"MermaidMaxSourceCharacters": func() int {
return setting.MermaidMaxSourceCharacters
},
"Join": strings.Join,
"QueryEscape": url.QueryEscape,
"DotEscape": DotEscape,
"Iterate": func(arg interface{}) (items []uint64) {
count := uint64(0)
switch val := arg.(type) {
case uint64:
count = val
case *uint64:
count = *val
case int64:
if val < 0 {
val = 0
}
count = uint64(val)
case *int64:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case int:
if val < 0 {
val = 0
}
count = uint64(val)
case *int:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case uint:
count = uint64(val)
case *uint:
count = uint64(*val)
case int32:
if val < 0 {
val = 0
}
count = uint64(val)
case *int32:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case uint32:
count = uint64(val)
case *uint32:
count = uint64(*val)
case string:
cnt, _ := strconv.ParseInt(val, 10, 64)
if cnt < 0 {
cnt = 0
}
count = uint64(cnt)
}
if count <= 0 {
return items
}
for i := uint64(0); i < count; i++ {
items = append(items, i)
}
return items
},
"HasPrefix": strings.HasPrefix,
"CompareLink": func(baseRepo, repo *repo_model.Repository, branchName string) string {
var curBranch string
if repo.ID != baseRepo.ID {
@@ -484,128 +363,6 @@ func NewFuncMap() []template.FuncMap {
curBranch,
)
},
"RefShortName": func(ref string) string {
return git.RefName(ref).ShortName()
},
}}
}
// NewTextFuncMap returns functions for injecting to text templates
// It's a subset of those used for HTML and other templates
func NewTextFuncMap() []texttmpl.FuncMap {
return []texttmpl.FuncMap{map[string]interface{}{
"GoVer": func() string {
return util.ToTitleCase(runtime.Version())
},
"AppName": func() string {
return setting.AppName
},
"AppSubUrl": func() string {
return setting.AppSubURL
},
"AppUrl": func() string {
return setting.AppURL
},
"AppVer": func() string {
return setting.AppVer
},
"AppBuiltWith": func() string {
return setting.AppBuiltWith
},
"AppDomain": func() string {
return setting.Domain
},
"TimeSince": timeutil.TimeSince,
"TimeSinceUnix": timeutil.TimeSinceUnix,
"DateFmtLong": func(t time.Time) string {
return t.Format(time.RFC1123Z)
},
"DateFmtShort": func(t time.Time) string {
return t.Format("Jan 02, 2006")
},
"SubStr": func(str string, start, length int) string {
if len(str) == 0 {
return ""
}
end := start + length
if length == -1 {
end = len(str)
}
if len(str) < end {
return str
}
return str[start:end]
},
"EllipsisString": base.EllipsisString,
"URLJoin": util.URLJoin,
"Dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
"Printf": fmt.Sprintf,
"Escape": Escape,
"Sec2Time": util.SecToTime,
"ParseDeadline": func(deadline string) []string {
return strings.Split(deadline, "|")
},
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values) == 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{})
for i := 0; i < len(values); i++ {
switch key := values[i].(type) {
case string:
i++
if i == len(values) {
return nil, errors.New("specify the key for non array values")
}
dict[key] = values[i]
case map[string]interface{}:
m := values[i].(map[string]interface{})
for i, v := range m {
dict[i] = v
}
default:
return nil, errors.New("dict values must be maps")
}
}
return dict, nil
},
"percentage": func(n int, values ...int) float32 {
sum := 0
for i := 0; i < len(values); i++ {
sum += values[i]
}
return float32(n) * 100 / float32(sum)
},
"Add": func(a ...int) int {
sum := 0
for _, val := range a {
sum += val
}
return sum
},
"Mul": func(a ...int) int {
sum := 1
for _, val := range a {
sum *= val
}
return sum
},
"QueryEscape": url.QueryEscape,
}}
}
@@ -679,26 +436,11 @@ func Safe(raw string) template.HTML {
return template.HTML(raw)
}
// SafeJS renders raw as JS
func SafeJS(raw string) template.JS {
return template.JS(raw)
}
// Str2html render Markdown text to HTML
func Str2html(raw string) template.HTML {
return template.HTML(markup.Sanitize(raw))
}
// Escape escapes a HTML string
func Escape(raw string) string {
return html.EscapeString(raw)
}
// JSEscape escapes a JS string
func JSEscape(raw string) string {
return template.JSEscapeString(raw)
}
// DotEscape wraps a dots in names with ZWJ [U+200D] in order to prevent autolinkers from detecting these as urls
func DotEscape(raw string) string {
return strings.ReplaceAll(raw, ".", "\u200d.\u200d")
@@ -980,14 +722,6 @@ func ActionContent2Commits(act Actioner) *repository.PushCommits {
return push
}
// DiffTypeToStr returns diff type name
func DiffTypeToStr(diffType int) string {
diffTypes := map[int]string{
1: "add", 2: "modify", 3: "del", 4: "rename", 5: "copy",
}
return diffTypes[diffType]
}
// DiffLineTypeToStr returns diff line type name
func DiffLineTypeToStr(diffType int) string {
switch diffType {
@@ -1011,25 +745,6 @@ func MigrationIcon(hostname string) string {
}
}
func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) {
// Split template into subject and body
var subjectContent []byte
bodyContent := content
loc := mailSubjectSplit.FindIndex(content)
if loc != nil {
subjectContent = content[0:loc[0]]
bodyContent = content[loc[1]:]
}
if _, err := stpl.New(name).
Parse(string(subjectContent)); err != nil {
log.Warn("Failed to parse template [%s/subject]: %v", name, err)
}
if _, err := btpl.New(name).
Parse(string(bodyContent)); err != nil {
log.Warn("Failed to parse template [%s/body]: %v", name, err)
}
}
type remoteAddress struct {
Address string
Username string
@@ -1067,10 +782,20 @@ func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteNa
return a
}
// JsPrettyNumber renders a number using english decimal separators, e.g. 1,200 and subsequent
// JS will replace the number with locale-specific separators, based on the user's selected language
func JsPrettyNumber(i interface{}) template.HTML {
num := util.NumberIntoInt64(i)
return template.HTML(`<span class="js-pretty-number" data-value="` + strconv.FormatInt(num, 10) + `">` + base.PrettyNumber(num) + `</span>`)
// LocaleNumber renders a number with a Custom Element, browser will render it with a locale number
func LocaleNumber(v interface{}) template.HTML {
num, _ := util.ToInt64(v)
return template.HTML(fmt.Sprintf(`<gitea-locale-number data-number="%d">%d</gitea-locale-number>`, num, num))
}
// Eval the expression and return the result, see the comment of eval.Expr for details.
// To use this helper function in templates, pass each token as a separate parameter.
//
// {{ $int64 := Eval $var "+" 1 }}
// {{ $float64 := Eval $var "+" 1.0 }}
//
// Golang's template supports comparable int types, so the int64 result can be used in later statements like {{if lt $int64 10}}
func Eval(tokens ...any) (any, error) {
n, err := eval.Expr(tokens...)
return n.Value, err
}
+212 -203
View File
@@ -4,37 +4,94 @@
package templates
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync/atomic"
texttemplate "text/template"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/watcher"
"github.com/unrolled/render"
"code.gitea.io/gitea/modules/util"
)
var (
rendererKey interface{} = "templatesHtmlRenderer"
var rendererKey interface{} = "templatesHtmlRenderer"
templateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`)
notDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): function "(.*)" not defined`)
unexpectedError = regexp.MustCompile(`^template: (.*):([0-9]+): unexpected "(.*)" in operand`)
expectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): expected end; found (.*)`)
)
type HTMLRender struct {
templates atomic.Pointer[template.Template]
}
var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
func (h *HTMLRender) HTML(w io.Writer, status int, name string, data interface{}) error {
if respWriter, ok := w.(http.ResponseWriter); ok {
if respWriter.Header().Get("Content-Type") == "" {
respWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
}
respWriter.WriteHeader(status)
}
t, err := h.TemplateLookup(name)
if err != nil {
return texttemplate.ExecError{Name: name, Err: err}
}
return t.Execute(w, data)
}
func (h *HTMLRender) TemplateLookup(name string) (*template.Template, error) {
tmpls := h.templates.Load()
if tmpls == nil {
return nil, ErrTemplateNotInitialized
}
tmpl := tmpls.Lookup(name)
if tmpl == nil {
return nil, util.ErrNotExist
}
return tmpl, nil
}
func (h *HTMLRender) CompileTemplates() error {
extSuffix := ".tmpl"
tmpls := template.New("")
assets := AssetFS()
files, err := ListWebTemplateAssetNames(assets)
if err != nil {
return nil
}
for _, file := range files {
if !strings.HasSuffix(file, extSuffix) {
continue
}
name := strings.TrimSuffix(file, extSuffix)
tmpl := tmpls.New(filepath.ToSlash(name))
for _, fm := range NewFuncMap() {
tmpl.Funcs(fm)
}
buf, err := assets.ReadFile(file)
if err != nil {
return err
}
if _, err = tmpl.Parse(string(buf)); err != nil {
return err
}
}
h.templates.Store(tmpls)
return nil
}
// HTMLRenderer returns the current html renderer for the context or creates and stores one within the context for future use
func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
rendererInterface := ctx.Value(rendererKey)
if rendererInterface != nil {
renderer, ok := rendererInterface.(*render.Render)
if ok {
return ctx, renderer
}
func HTMLRenderer(ctx context.Context) (context.Context, *HTMLRender) {
if renderer, ok := ctx.Value(rendererKey).(*HTMLRender); ok {
return ctx, renderer
}
rendererType := "static"
@@ -43,220 +100,172 @@ func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
}
log.Log(1, log.DEBUG, "Creating "+rendererType+" HTML Renderer")
compilingTemplates := true
defer func() {
if !compilingTemplates {
return
}
panicked := recover()
if panicked == nil {
return
}
// OK try to handle the panic...
err, ok := panicked.(error)
if ok {
handlePanicError(err)
}
log.Fatal("PANIC: Unable to compile templates!\n%v\n\nStacktrace:\n%s", panicked, log.Stack(2))
}()
renderer := render.New(render.Options{
Extensions: []string{".tmpl"},
Directory: "templates",
Funcs: NewFuncMap(),
Asset: GetAsset,
AssetNames: GetTemplateAssetNames,
UseMutexLock: !setting.IsProd,
IsDevelopment: false,
DisableHTTPErrorRendering: true,
})
compilingTemplates = false
renderer := &HTMLRender{}
if err := renderer.CompileTemplates(); err != nil {
p := &templateErrorPrettier{assets: AssetFS()}
wrapFatal(p.handleFuncNotDefinedError(err))
wrapFatal(p.handleUnexpectedOperandError(err))
wrapFatal(p.handleExpectedEndError(err))
wrapFatal(p.handleGenericTemplateError(err))
log.Fatal("HTMLRenderer CompileTemplates error: %v", err)
}
if !setting.IsProd {
watcher.CreateWatcher(ctx, "HTML Templates", &watcher.CreateWatcherOpts{
PathsCallback: walkTemplateFiles,
BetweenCallback: func() {
defer func() {
if err := recover(); err != nil {
log.Error("PANIC: %v\n%s", err, log.Stack(2))
}
}()
renderer.CompileTemplates()
},
go AssetFS().WatchLocalChanges(ctx, func() {
if err := renderer.CompileTemplates(); err != nil {
log.Error("Template error: %v\n%s", err, log.Stack(2))
}
})
}
return context.WithValue(ctx, rendererKey, renderer), renderer
}
func handlePanicError(err error) {
wrapFatal(handleNotDefinedPanicError(err))
wrapFatal(handleUnexpected(err))
wrapFatal(handleExpectedEnd(err))
wrapFatal(handleGenericTemplateError(err))
}
func wrapFatal(format string, args []interface{}) {
if format == "" {
func wrapFatal(msg string) {
if msg == "" {
return
}
log.FatalWithSkip(1, format, args...)
log.FatalWithSkip(1, "Unable to compile templates, %s", msg)
}
func handleGenericTemplateError(err error) (string, []interface{}) {
groups := templateError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
}
templateName, lineNumberStr, message := groups[1], groups[2], groups[3]
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, "", -1)
return "PANIC: Unable to compile templates!\n%s in template file %s at line %d:\n\n%s\nStacktrace:\n\n%s", []interface{}{message, filename, lineNumber, log.NewColoredValue(line, log.Reset), log.Stack(2)}
type templateErrorPrettier struct {
assets *assetfs.LayeredFS
}
func handleNotDefinedPanicError(err error) (string, []interface{}) {
groups := notDefinedError.FindStringSubmatch(err.Error())
var reGenericTemplateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`)
func (p *templateErrorPrettier) handleGenericTemplateError(err error) string {
groups := reGenericTemplateError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
return ""
}
templateName, lineNumberStr, functionName := groups[1], groups[2], groups[3]
functionName, _ = strconv.Unquote(`"` + functionName + `"`)
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, functionName, -1)
return "PANIC: Unable to compile templates!\nUndefined function %q in template file %s at line %d:\n\n%s", []interface{}{functionName, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
tmplName, lineStr, message := groups[1], groups[2], groups[3]
return p.makeDetailedError(message, tmplName, lineStr, -1, "")
}
func handleUnexpected(err error) (string, []interface{}) {
groups := unexpectedError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
}
var reFuncNotDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): (function "(.*)" not defined)`)
templateName, lineNumberStr, unexpected := groups[1], groups[2], groups[3]
func (p *templateErrorPrettier) handleFuncNotDefinedError(err error) string {
groups := reFuncNotDefinedError.FindStringSubmatch(err.Error())
if len(groups) != 5 {
return ""
}
tmplName, lineStr, message, funcName := groups[1], groups[2], groups[3], groups[4]
funcName, _ = strconv.Unquote(`"` + funcName + `"`)
return p.makeDetailedError(message, tmplName, lineStr, -1, funcName)
}
var reUnexpectedOperandError = regexp.MustCompile(`^template: (.*):([0-9]+): (unexpected "(.*)" in operand)`)
func (p *templateErrorPrettier) handleUnexpectedOperandError(err error) string {
groups := reUnexpectedOperandError.FindStringSubmatch(err.Error())
if len(groups) != 5 {
return ""
}
tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
unexpected, _ = strconv.Unquote(`"` + unexpected + `"`)
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, unexpected, -1)
return "PANIC: Unable to compile templates!\nUnexpected %q in template file %s at line %d:\n\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
}
func handleExpectedEnd(err error) (string, []interface{}) {
groups := expectedEndError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
var reExpectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): (expected end; found (.*))`)
func (p *templateErrorPrettier) handleExpectedEndError(err error) string {
groups := reExpectedEndError.FindStringSubmatch(err.Error())
if len(groups) != 5 {
return ""
}
templateName, lineNumberStr, unexpected := groups[1], groups[2], groups[3]
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, unexpected, -1)
return "PANIC: Unable to compile templates!\nMissing end with unexpected %q in template file %s at line %d:\n\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
}
const dashSeparator = "----------------------------------------------------------------------\n"
var (
reTemplateExecutingError = regexp.MustCompile(`^template: (.*):([1-9][0-9]*):([1-9][0-9]*): (executing .*)`)
reTemplateExecutingErrorMsg = regexp.MustCompile(`^executing "(.*)" at <(.*)>: `)
)
// GetLineFromTemplate returns a line from a template with some context
func GetLineFromTemplate(templateName string, targetLineNum int, target string, position int) string {
bs, err := GetAsset("templates/" + templateName + ".tmpl")
func (p *templateErrorPrettier) handleTemplateRenderingError(err error) string {
if groups := reTemplateExecutingError.FindStringSubmatch(err.Error()); len(groups) > 0 {
tmplName, lineStr, posStr, msgPart := groups[1], groups[2], groups[3], groups[4]
target := ""
if groups = reTemplateExecutingErrorMsg.FindStringSubmatch(msgPart); len(groups) > 0 {
target = groups[2]
}
return p.makeDetailedError(msgPart, tmplName, lineStr, posStr, target)
} else if execErr, ok := err.(texttemplate.ExecError); ok {
layerName := p.assets.GetFileLayerName(execErr.Name + ".tmpl")
return fmt.Sprintf("asset from: %s, %s", layerName, err.Error())
} else {
return err.Error()
}
}
func HandleTemplateRenderingError(err error) string {
p := &templateErrorPrettier{assets: AssetFS()}
return p.handleTemplateRenderingError(err)
}
const dashSeparator = "----------------------------------------------------------------------"
func (p *templateErrorPrettier) makeDetailedError(errMsg, tmplName string, lineNum, posNum any, target string) string {
code, layer, err := p.assets.ReadLayeredFile(tmplName + ".tmpl")
if err != nil {
return fmt.Sprintf("(unable to read template file: %v)", err)
return fmt.Sprintf("template error: %s, and unable to find template file %q", errMsg, tmplName)
}
sb := &strings.Builder{}
// Write the header
sb.WriteString(dashSeparator)
var lineBs []byte
// Iterate through the lines from the asset file to find the target line
for start, currentLineNum := 0, 1; currentLineNum <= targetLineNum && start < len(bs); currentLineNum++ {
// Find the next new line
end := bytes.IndexByte(bs[start:], '\n')
// adjust the end to be a direct pointer in to []byte
if end < 0 {
end = len(bs)
} else {
end += start
}
// set lineBs to the current line []byte
lineBs = bs[start:end]
// move start to after the current new line position
start = end + 1
// Write 2 preceding lines + the target line
if targetLineNum-currentLineNum < 3 {
_, _ = sb.Write(lineBs)
_ = sb.WriteByte('\n')
}
line, err := util.ToInt64(lineNum)
if err != nil {
return fmt.Sprintf("template error: %s, unable to parse template %q line number %q", errMsg, tmplName, lineNum)
}
// If there is a provided target to look for in the line add a pointer to it
// e.g. ^^^^^^^
if target != "" {
targetPos := bytes.Index(lineBs, []byte(target))
if targetPos >= 0 {
position = targetPos
}
pos, err := util.ToInt64(posNum)
if err != nil {
return fmt.Sprintf("template error: %s, unable to parse template %q pos number %q", errMsg, tmplName, posNum)
}
if position >= 0 {
// take the current line and replace preceding text with whitespace (except for tab)
for i := range lineBs[:position] {
if lineBs[i] != '\t' {
lineBs[i] = ' '
}
}
detail := extractErrorLine(code, int(line), int(pos), target)
// write the preceding "space"
_, _ = sb.Write(lineBs[:position])
// Now write the ^^ pointer
targetLen := len(target)
if targetLen == 0 {
targetLen = 1
}
_, _ = sb.WriteString(strings.Repeat("^", targetLen))
_ = sb.WriteByte('\n')
var msg string
if pos >= 0 {
msg = fmt.Sprintf("template error: %s:%s:%d:%d : %s", layer, tmplName, line, pos, errMsg)
} else {
msg = fmt.Sprintf("template error: %s:%s:%d : %s", layer, tmplName, line, errMsg)
}
// Finally write the footer
sb.WriteString(dashSeparator)
return sb.String()
return msg + "\n" + dashSeparator + "\n" + detail + "\n" + dashSeparator
}
func extractErrorLine(code []byte, lineNum, posNum int, target string) string {
b := bufio.NewReader(bytes.NewReader(code))
var line []byte
var err error
for i := 0; i < lineNum; i++ {
if line, err = b.ReadBytes('\n'); err != nil {
if i == lineNum-1 && errors.Is(err, io.EOF) {
err = nil
}
break
}
}
if err != nil {
return fmt.Sprintf("unable to find target line %d", lineNum)
}
line = bytes.TrimRight(line, "\r\n")
var indicatorLine []byte
targetBytes := []byte(target)
targetLen := len(targetBytes)
for i := 0; i < len(line); {
if posNum == -1 && target != "" && bytes.HasPrefix(line[i:], targetBytes) {
for j := 0; j < targetLen && i < len(line); j++ {
indicatorLine = append(indicatorLine, '^')
i++
}
} else if i == posNum {
indicatorLine = append(indicatorLine, '^')
i++
} else {
if line[i] == '\t' {
indicatorLine = append(indicatorLine, '\t')
} else {
indicatorLine = append(indicatorLine, ' ')
}
i++
}
}
// if the indicatorLine only contains spaces, trim it together
return strings.TrimRight(string(line)+"\n"+string(indicatorLine), " \t\r\n")
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"errors"
"html/template"
"os"
"strings"
"testing"
"code.gitea.io/gitea/modules/assetfs"
"github.com/stretchr/testify/assert"
)
func TestExtractErrorLine(t *testing.T) {
cases := []struct {
code string
line int
pos int
target string
expect string
}{
{"hello world\nfoo bar foo bar\ntest", 2, -1, "bar", `
foo bar foo bar
^^^ ^^^
`},
{"hello world\nfoo bar foo bar\ntest", 2, 4, "bar", `
foo bar foo bar
^
`},
{
"hello world\nfoo bar foo bar\ntest", 2, 4, "",
`
foo bar foo bar
^
`,
},
{
"hello world\nfoo bar foo bar\ntest", 5, 0, "",
`unable to find target line 5`,
},
}
for _, c := range cases {
actual := extractErrorLine([]byte(c.code), c.line, c.pos, c.target)
assert.Equal(t, strings.TrimSpace(c.expect), strings.TrimSpace(actual))
}
}
func TestHandleError(t *testing.T) {
dir := t.TempDir()
p := &templateErrorPrettier{assets: assetfs.Layered(assetfs.Local("tmp", dir))}
test := func(s string, h func(error) string, expect string) {
err := os.WriteFile(dir+"/test.tmpl", []byte(s), 0o644)
assert.NoError(t, err)
tmpl := template.New("test")
_, err = tmpl.Parse(s)
assert.Error(t, err)
msg := h(err)
assert.EqualValues(t, strings.TrimSpace(expect), strings.TrimSpace(msg))
}
test("{{", p.handleGenericTemplateError, `
template error: tmp:test:1 : unclosed action
----------------------------------------------------------------------
{{
----------------------------------------------------------------------
`)
test("{{Func}}", p.handleFuncNotDefinedError, `
template error: tmp:test:1 : function "Func" not defined
----------------------------------------------------------------------
{{Func}}
^^^^
----------------------------------------------------------------------
`)
test("{{'x'3}}", p.handleUnexpectedOperandError, `
template error: tmp:test:1 : unexpected "3" in operand
----------------------------------------------------------------------
{{'x'3}}
^
----------------------------------------------------------------------
`)
// no idea about how to trigger such strange error, so mock an error to test it
err := os.WriteFile(dir+"/test.tmpl", []byte("god knows XXX"), 0o644)
assert.NoError(t, err)
expectedMsg := `
template error: tmp:test:1 : expected end; found XXX
----------------------------------------------------------------------
god knows XXX
^^^
----------------------------------------------------------------------
`
actualMsg := p.handleExpectedEndError(errors.New("template: test:1: expected end; found XXX"))
assert.EqualValues(t, strings.TrimSpace(expectedMsg), strings.TrimSpace(actualMsg))
}
+53 -53
View File
@@ -6,73 +6,76 @@ package templates
import (
"context"
"html/template"
"io/fs"
"os"
"strings"
texttmpl "text/template"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/watcher"
)
// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
func mailSubjectTextFuncMap() texttmpl.FuncMap {
return texttmpl.FuncMap{
"dict": dict,
"Eval": Eval,
"EllipsisString": base.EllipsisString,
"AppName": func() string {
return setting.AppName
},
"AppDomain": func() string { // documented in mail-templates.md
return setting.Domain
},
}
}
func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) {
// Split template into subject and body
var subjectContent []byte
bodyContent := content
loc := mailSubjectSplit.FindIndex(content)
if loc != nil {
subjectContent = content[0:loc[0]]
bodyContent = content[loc[1]:]
}
if _, err := stpl.New(name).
Parse(string(subjectContent)); err != nil {
log.Warn("Failed to parse template [%s/subject]: %v", name, err)
}
if _, err := btpl.New(name).
Parse(string(bodyContent)); err != nil {
log.Warn("Failed to parse template [%s/body]: %v", name, err)
}
}
// Mailer provides the templates required for sending notification mails.
func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
for _, funcs := range NewTextFuncMap() {
subjectTemplates.Funcs(funcs)
}
subjectTemplates := texttmpl.New("")
bodyTemplates := template.New("")
subjectTemplates.Funcs(mailSubjectTextFuncMap())
for _, funcs := range NewFuncMap() {
bodyTemplates.Funcs(funcs)
}
assetFS := AssetFS()
refreshTemplates := func() {
for _, assetPath := range BuiltinAssetNames() {
if !strings.HasPrefix(assetPath, "mail/") {
continue
}
if !strings.HasSuffix(assetPath, ".tmpl") {
continue
}
content, err := BuiltinAsset(assetPath)
if err != nil {
log.Warn("Failed to read embedded %s template. %v", assetPath, err)
continue
}
assetName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/")
log.Trace("Adding built-in mailer template for %s", assetName)
buildSubjectBodyTemplate(subjectTemplates,
bodyTemplates,
assetName,
content)
assetPaths, err := ListMailTemplateAssetNames(assetFS)
if err != nil {
log.Error("Failed to list mail templates: %v", err)
return
}
if err := walkMailerTemplates(func(path, name string, d fs.DirEntry, err error) error {
for _, assetPath := range assetPaths {
content, layerName, err := assetFS.ReadLayeredFile(assetPath)
if err != nil {
return err
log.Warn("Failed to read mail template %s by %s: %v", assetPath, layerName, err)
continue
}
if d.IsDir() {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
log.Warn("Failed to read custom %s template. %v", path, err)
return nil
}
assetName := strings.TrimSuffix(name, ".tmpl")
log.Trace("Adding mailer template for %s from %q", assetName, path)
buildSubjectBodyTemplate(subjectTemplates,
bodyTemplates,
assetName,
content)
return nil
}); err != nil && !os.IsNotExist(err) {
log.Warn("Error whilst walking mailer templates directories. %v", err)
tmplName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/")
log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName)
buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content)
}
}
@@ -81,10 +84,7 @@ func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
if !setting.IsProd {
// Now subjectTemplates and bodyTemplates are both synchronized
// thus it is safe to call refresh from a different goroutine
watcher.CreateWatcher(ctx, "Mailer Templates", &watcher.CreateWatcherOpts{
PathsCallback: walkMailerTemplates,
BetweenCallback: refreshTemplates,
})
go assetFS.WatchLocalChanges(ctx, refreshTemplates)
}
return subjectTemplates, bodyTemplates
+3 -100
View File
@@ -6,114 +6,17 @@
package templates
import (
"html/template"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
texttmpl "text/template"
"time"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/timeutil"
)
var (
subjectTemplates = texttmpl.New("")
bodyTemplates = template.New("")
)
// GlobalModTime provide a global mod time for embedded asset files
func GlobalModTime(filename string) time.Time {
return timeutil.GetExecutableModTime()
}
// GetAssetFilename returns the filename of the provided asset
func GetAssetFilename(name string) (string, error) {
filename := filepath.Join(setting.CustomPath, name)
_, err := os.Stat(filename)
if err != nil && !os.IsNotExist(err) {
return name, err
} else if err == nil {
return filename, nil
}
return "(builtin) " + name, nil
}
// GetAsset get a special asset, only for chi
func GetAsset(name string) ([]byte, error) {
bs, err := os.ReadFile(filepath.Join(setting.CustomPath, name))
if err != nil && !os.IsNotExist(err) {
return nil, err
} else if err == nil {
return bs, nil
}
return BuiltinAsset(strings.TrimPrefix(name, "templates/"))
}
// GetFiles calls a callback for each template asset
func walkTemplateFiles(callback func(path, name string, d fs.DirEntry, err error) error) error {
if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// GetTemplateAssetNames only for chi
func GetTemplateAssetNames() []string {
realFS := Assets.(vfsgen۰FS)
tmpls := make([]string, 0, len(realFS))
for k := range realFS {
if strings.HasPrefix(k, "/mail/") {
continue
}
tmpls = append(tmpls, "templates/"+k[1:])
}
customDir := path.Join(setting.CustomPath, "templates")
customTmpls := getDirTemplateAssetNames(customDir)
return append(tmpls, customTmpls...)
}
func walkMailerTemplates(callback func(path, name string, d fs.DirEntry, err error) error) error {
if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates", "mail"), false, callback); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// BuiltinAsset reads the provided asset from the builtin embedded assets
func BuiltinAsset(name string) ([]byte, error) {
f, err := Assets.Open("/" + name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
// BuiltinAssetNames returns the names of the built-in embedded assets
func BuiltinAssetNames() []string {
realFS := Assets.(vfsgen۰FS)
results := make([]string, 0, len(realFS))
for k := range realFS {
results = append(results, k[1:])
}
return results
}
// BuiltinAssetIsDir returns if a provided asset is a directory
func BuiltinAssetIsDir(name string) (bool, error) {
if f, err := Assets.Open("/" + name); err != nil {
return false, err
} else {
defer f.Close()
if fi, err := f.Stat(); err != nil {
return false, err
} else {
return fi.IsDir(), nil
}
}
func BuiltinAssets() *assetfs.Layer {
return assetfs.Bindata("builtin(bindata)", Assets)
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"fmt"
"reflect"
)
func dictMerge(base map[string]any, arg any) bool {
if arg == nil {
return true
}
rv := reflect.ValueOf(arg)
if rv.Kind() == reflect.Map {
for _, k := range rv.MapKeys() {
base[k.String()] = rv.MapIndex(k).Interface()
}
return true
}
return false
}
// dict is a helper function for creating a map[string]any from a list of key-value pairs.
// If the key is dot ".", the value is merged into the base map, just like Golang template's dot syntax: dot means current
// The dot syntax is highly discouraged because it might cause unclear key conflicts. It's always good to use explicit keys.
func dict(args ...any) (map[string]any, error) {
if len(args)%2 != 0 {
return nil, fmt.Errorf("invalid dict constructor syntax: must have key-value pairs")
}
m := make(map[string]any, len(args)/2)
for i := 0; i < len(args); i += 2 {
key, ok := args[i].(string)
if !ok {
return nil, fmt.Errorf("invalid dict constructor syntax: unable to merge args[%d]", i)
}
if key == "." {
if ok = dictMerge(m, args[i+1]); !ok {
return nil, fmt.Errorf("invalid dict constructor syntax: dot arg[%d] must be followed by a dict", i)
}
} else {
m[key] = args[i+1]
}
}
return m, nil
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDict(t *testing.T) {
type M map[string]any
cases := []struct {
args []any
want map[string]any
}{
{[]any{"a", 1, "b", 2}, M{"a": 1, "b": 2}},
{[]any{".", M{"base": 1}, "b", 2}, M{"base": 1, "b": 2}},
{[]any{"a", 1, ".", M{"extra": 2}}, M{"a": 1, "extra": 2}},
{[]any{"a", 1, ".", map[string]int{"int": 2}}, M{"a": 1, "int": 2}},
{[]any{".", nil, "b", 2}, M{"b": 2}},
}
for _, c := range cases {
got, err := dict(c.args...)
if assert.NoError(t, err) {
assert.EqualValues(t, c.want, got)
}
}
bads := []struct {
args []any
}{
{[]any{"a", 1, "b"}},
{[]any{1}},
{[]any{struct{}{}}},
}
for _, c := range bads {
_, err := dict(c.args...)
assert.Error(t, err)
}
}
+3 -4
View File
@@ -22,7 +22,6 @@ import (
chi "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/unrolled/render"
)
// MockContext mock context for unit tests
@@ -134,11 +133,11 @@ func (rw *mockResponseWriter) Push(target string, opts *http.PushOptions) error
type mockRender struct{}
func (tr *mockRender) TemplateLookup(tmpl string) *template.Template {
return nil
func (tr *mockRender) TemplateLookup(tmpl string) (*template.Template, error) {
return nil, nil
}
func (tr *mockRender) HTML(w io.Writer, status int, _ string, _ interface{}, _ ...render.HTMLOptions) error {
func (tr *mockRender) HTML(w io.Writer, status int, _ string, _ interface{}) error {
if resp, ok := w.(http.ResponseWriter); ok {
resp.WriteHeader(status)
}
+6 -129
View File
@@ -6,11 +6,9 @@ package timeutil
import (
"fmt"
"html/template"
"math"
"strings"
"time"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
)
@@ -24,10 +22,6 @@ const (
Year = 12 * Month
)
func round(s float64) int64 {
return int64(math.Round(s))
}
func computeTimeDiffFloor(diff int64, lang translation.Locale) (int64, string) {
diffStr := ""
switch {
@@ -86,94 +80,6 @@ func computeTimeDiffFloor(diff int64, lang translation.Locale) (int64, string) {
return diff, diffStr
}
func computeTimeDiff(diff int64, lang translation.Locale) (int64, string) {
diffStr := ""
switch {
case diff <= 0:
diff = 0
diffStr = lang.Tr("tool.now")
case diff < 2:
diff = 0
diffStr = lang.Tr("tool.1s")
case diff < 1*Minute:
diffStr = lang.Tr("tool.seconds", diff)
diff = 0
case diff < Minute+Minute/2:
diff -= 1 * Minute
diffStr = lang.Tr("tool.1m")
case diff < 1*Hour:
minutes := round(float64(diff) / Minute)
if minutes > 1 {
diffStr = lang.Tr("tool.minutes", minutes)
} else {
diffStr = lang.Tr("tool.1m")
}
diff -= diff / Minute * Minute
case diff < Hour+Hour/2:
diff -= 1 * Hour
diffStr = lang.Tr("tool.1h")
case diff < 1*Day:
hours := round(float64(diff) / Hour)
if hours > 1 {
diffStr = lang.Tr("tool.hours", hours)
} else {
diffStr = lang.Tr("tool.1h")
}
diff -= diff / Hour * Hour
case diff < Day+Day/2:
diff -= 1 * Day
diffStr = lang.Tr("tool.1d")
case diff < 1*Week:
days := round(float64(diff) / Day)
if days > 1 {
diffStr = lang.Tr("tool.days", days)
} else {
diffStr = lang.Tr("tool.1d")
}
diff -= diff / Day * Day
case diff < Week+Week/2:
diff -= 1 * Week
diffStr = lang.Tr("tool.1w")
case diff < 1*Month:
weeks := round(float64(diff) / Week)
if weeks > 1 {
diffStr = lang.Tr("tool.weeks", weeks)
} else {
diffStr = lang.Tr("tool.1w")
}
diff -= diff / Week * Week
case diff < 1*Month+Month/2:
diff -= 1 * Month
diffStr = lang.Tr("tool.1mon")
case diff < 1*Year:
months := round(float64(diff) / Month)
if months > 1 {
diffStr = lang.Tr("tool.months", months)
} else {
diffStr = lang.Tr("tool.1mon")
}
diff -= diff / Month * Month
case diff < Year+Year/2:
diff -= 1 * Year
diffStr = lang.Tr("tool.1y")
default:
years := round(float64(diff) / Year)
if years > 1 {
diffStr = lang.Tr("tool.years", years)
} else {
diffStr = lang.Tr("tool.1y")
}
diff -= (diff / Year) * Year
}
return diff, diffStr
}
// MinutesToFriendly returns a user friendly string with number of minutes
// converted to hours and minutes.
func MinutesToFriendly(minutes int, lang translation.Locale) string {
@@ -208,43 +114,14 @@ func timeSincePro(then, now time.Time, lang translation.Locale) string {
return strings.TrimPrefix(timeStr, ", ")
}
func timeSince(then, now time.Time, lang translation.Locale) string {
return timeSinceUnix(then.Unix(), now.Unix(), lang)
}
func timeSinceUnix(then, now int64, lang translation.Locale) string {
lbl := "tool.ago"
diff := now - then
if then > now {
lbl = "tool.from_now"
diff = then - now
}
if diff <= 0 {
return lang.Tr("tool.now")
}
_, diffStr := computeTimeDiff(diff, lang)
return lang.Tr(lbl, diffStr)
}
// TimeSince calculates the time interval and generate user-friendly string.
// TimeSince renders relative time HTML given a time.Time
func TimeSince(then time.Time, lang translation.Locale) template.HTML {
return htmlTimeSince(then, time.Now(), lang)
timestamp := then.UTC().Format(time.RFC3339)
// declare data-tooltip-content attribute to switch from "title" tooltip to "tippy" tooltip
return template.HTML(fmt.Sprintf(`<relative-time class="time-since" prefix="%s" datetime="%s" data-tooltip-content data-tooltip-interactive="true">%s</relative-time>`, lang.Tr("on_date"), timestamp, timestamp))
}
func htmlTimeSince(then, now time.Time, lang translation.Locale) template.HTML {
return template.HTML(fmt.Sprintf(`<span class="time-since" data-tooltip-content="%s" data-tooltip-interactive="true">%s</span>`,
then.In(setting.DefaultUILocation).Format(GetTimeFormat(lang.Language())),
timeSince(then, now, lang)))
}
// TimeSinceUnix calculates the time interval and generate user-friendly string.
// TimeSinceUnix renders relative time HTML given a TimeStamp
func TimeSinceUnix(then TimeStamp, lang translation.Locale) template.HTML {
return htmlTimeSinceUnix(then, TimeStamp(time.Now().Unix()), lang)
}
func htmlTimeSinceUnix(then, now TimeStamp, lang translation.Locale) template.HTML {
return template.HTML(fmt.Sprintf(`<span class="time-since" data-tooltip-content="%s" data-tooltip-interactive="true">%s</span>`,
then.FormatInLocation(GetTimeFormat(lang.Language()), setting.DefaultUILocation),
timeSinceUnix(int64(then), int64(now), lang)))
return TimeSince(then.AsLocalTime(), lang)
}
-95
View File
@@ -5,7 +5,6 @@ package timeutil
import (
"context"
"fmt"
"os"
"testing"
"time"
@@ -40,46 +39,6 @@ func TestMain(m *testing.M) {
os.Exit(retVal)
}
func TestTimeSince(t *testing.T) {
assert.Equal(t, "now", timeSince(BaseDate, BaseDate, translation.NewLocale("en-US")))
// test that each diff in `diffs` yields the expected string
test := func(expected string, diffs ...time.Duration) {
t.Run(expected, func(t *testing.T) {
for _, diff := range diffs {
actual := timeSince(BaseDate, BaseDate.Add(diff), translation.NewLocale("en-US"))
assert.Equal(t, translation.NewLocale("en-US").Tr("tool.ago", expected), actual)
actual = timeSince(BaseDate.Add(diff), BaseDate, translation.NewLocale("en-US"))
assert.Equal(t, translation.NewLocale("en-US").Tr("tool.from_now", expected), actual)
}
})
}
test("1 second", time.Second, time.Second+50*time.Millisecond)
test("2 seconds", 2*time.Second, 2*time.Second+50*time.Millisecond)
test("1 minute", time.Minute, time.Minute+29*time.Second)
test("2 minutes", 2*time.Minute, time.Minute+30*time.Second)
test("2 minutes", 2*time.Minute, 2*time.Minute+29*time.Second)
test("1 hour", time.Hour, time.Hour+29*time.Minute)
test("2 hours", 2*time.Hour, time.Hour+30*time.Minute)
test("2 hours", 2*time.Hour, 2*time.Hour+29*time.Minute)
test("3 hours", 3*time.Hour, 2*time.Hour+30*time.Minute)
test("1 day", DayDur, DayDur+11*time.Hour)
test("2 days", 2*DayDur, DayDur+12*time.Hour)
test("2 days", 2*DayDur, 2*DayDur+11*time.Hour)
test("3 days", 3*DayDur, 2*DayDur+12*time.Hour)
test("1 week", WeekDur, WeekDur+3*DayDur)
test("2 weeks", 2*WeekDur, WeekDur+4*DayDur)
test("2 weeks", 2*WeekDur, 2*WeekDur+3*DayDur)
test("3 weeks", 3*WeekDur, 2*WeekDur+4*DayDur)
test("1 month", MonthDur, MonthDur+14*DayDur)
test("2 months", 2*MonthDur, MonthDur+15*DayDur)
test("2 months", 2*MonthDur, 2*MonthDur+14*DayDur)
test("1 year", YearDur, YearDur+5*MonthDur)
test("2 years", 2*YearDur, YearDur+6*MonthDur)
test("2 years", 2*YearDur, 2*YearDur+5*MonthDur)
test("3 years", 3*YearDur, 2*YearDur+6*MonthDur)
}
func TestTimeSincePro(t *testing.T) {
assert.Equal(t, "now", timeSincePro(BaseDate, BaseDate, translation.NewLocale("en-US")))
@@ -113,60 +72,6 @@ func TestTimeSincePro(t *testing.T) {
12*time.Minute+17*time.Second)
}
func TestHtmlTimeSince(t *testing.T) {
setting.TimeFormat = time.UnixDate
setting.DefaultUILocation = time.UTC
// test that `diff` yields a result containing `expected`
test := func(expected string, diff time.Duration) {
actual := htmlTimeSince(BaseDate, BaseDate.Add(diff), translation.NewLocale("en-US"))
assert.Contains(t, actual, `data-tooltip-content="Sat Jan 1 00:00:00 UTC 2000"`)
assert.Contains(t, actual, expected)
}
test("1 second", time.Second)
test("3 minutes", 3*time.Minute+5*time.Second)
test("1 day", DayDur+11*time.Hour)
test("1 week", WeekDur+3*DayDur)
test("3 months", 3*MonthDur+2*WeekDur)
test("2 years", 2*YearDur)
test("3 years", 2*YearDur+11*MonthDur+4*WeekDur)
}
func TestComputeTimeDiff(t *testing.T) {
// test that for each offset in offsets,
// computeTimeDiff(base + offset) == (offset, str)
test := func(base int64, str string, offsets ...int64) {
for _, offset := range offsets {
t.Run(fmt.Sprintf("%s:%d", str, offset), func(t *testing.T) {
diff, diffStr := computeTimeDiff(base+offset, translation.NewLocale("en-US"))
assert.Equal(t, offset, diff)
assert.Equal(t, str, diffStr)
})
}
}
test(0, "now", 0)
test(1, "1 second", 0)
test(2, "2 seconds", 0)
test(Minute, "1 minute", 0, 1, 29)
test(Minute, "2 minutes", 30, Minute-1)
test(2*Minute, "2 minutes", 0, 29)
test(2*Minute, "3 minutes", 30, Minute-1)
test(Hour, "1 hour", 0, 1, 29*Minute)
test(Hour, "2 hours", 30*Minute, Hour-1)
test(5*Hour, "5 hours", 0, 29*Minute)
test(Day, "1 day", 0, 1, 11*Hour)
test(Day, "2 days", 12*Hour, Day-1)
test(5*Day, "5 days", 0, 11*Hour)
test(Week, "1 week", 0, 1, 3*Day)
test(Week, "2 weeks", 4*Day, Week-1)
test(3*Week, "3 weeks", 0, 3*Day)
test(Month, "1 month", 0, 1)
test(Month, "2 months", 16*Day, Month-1)
test(10*Month, "10 months", 0, 13*Day)
test(Year, "1 year", 0, 179*Day)
test(Year, "2 years", 180*Day, Year-1)
test(3*Year, "3 years", 0, 179*Day)
}
func TestMinutesToFriendly(t *testing.T) {
// test that a number of minutes yields the expected string
test := func(expected string, minutes int) {
+2 -3
View File
@@ -64,9 +64,8 @@ func (ts TimeStamp) AsLocalTime() time.Time {
}
// AsTimeInLocation convert timestamp as time.Time in Local locale
func (ts TimeStamp) AsTimeInLocation(loc *time.Location) (tm time.Time) {
tm = time.Unix(int64(ts), 0).In(loc)
return tm
func (ts TimeStamp) AsTimeInLocation(loc *time.Location) time.Time {
return time.Unix(int64(ts), 0).In(loc)
}
// AsTimePtr convert timestamp as *time.Time in Local locale
+54
View File
@@ -4,6 +4,7 @@
package i18n
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -75,3 +76,56 @@ c=22
assert.Equal(t, "21", ls.Tr("lang1", "b"))
assert.Equal(t, "22", ls.Tr("lang1", "c"))
}
func TestLocaleStoreQuirks(t *testing.T) {
const nl = "\n"
q := func(q1, s string, q2 ...string) string {
return q1 + s + strings.Join(q2, "")
}
testDataList := []struct {
in string
out string
hint string
}{
{` xx`, `xx`, "simple, no quote"},
{`" xx"`, ` xx`, "simple, double-quote"},
{`' xx'`, ` xx`, "simple, single-quote"},
{"` xx`", ` xx`, "simple, back-quote"},
{`x\"y`, `x\"y`, "no unescape, simple"},
{q(`"`, `x\"y`, `"`), `"x\"y"`, "unescape, double-quote"},
{q(`'`, `x\"y`, `'`), `x\"y`, "no unescape, single-quote"},
{q("`", `x\"y`, "`"), `x\"y`, "no unescape, back-quote"},
{q(`"`, `x\"y`) + nl + "b=", `"x\"y`, "half open, double-quote"},
{q(`'`, `x\"y`) + nl + "b=", `'x\"y`, "half open, single-quote"},
{q("`", `x\"y`) + nl + "b=`", `x\"y` + nl + "b=", "half open, back-quote, multi-line"},
{`x ; y`, `x ; y`, "inline comment (;)"},
{`x # y`, `x # y`, "inline comment (#)"},
{`x \; y`, `x ; y`, `inline comment (\;)`},
{`x \# y`, `x # y`, `inline comment (\#)`},
}
for _, testData := range testDataList {
ls := NewLocaleStore()
err := ls.AddLocaleByIni("lang1", "Lang1", []byte("a="+testData.in), nil)
assert.NoError(t, err, testData.hint)
assert.Equal(t, testData.out, ls.Tr("lang1", "a"), testData.hint)
assert.NoError(t, ls.Close())
}
// TODO: Crowdin needs the strings to be quoted correctly and doesn't like incomplete quotes
// and Crowdin always outputs quoted strings if there are quotes in the strings.
// So, Gitea's `key="quoted" unquoted` content shouldn't be used on Crowdin directly,
// it should be converted to `key="\"quoted\" unquoted"` first.
// TODO: We can not use UnescapeValueDoubleQuotes=true, because there are a lot of back-quotes in en-US.ini,
// then Crowdin will output:
// > key = "`x \" y`"
// Then Gitea will read a string with back-quotes, which is incorrect.
// TODO: Crowdin might generate multi-line strings, quoted by double-quote, it's not supported by LocaleStore
// LocaleStore uses back-quote for multi-line strings, it's not supported by Crowdin.
// TODO: Crowdin doesn't support back-quote as string quoter, it mainly uses double-quote
// so, the following line will be parsed as: value="`first", comment="second`" on Crowdin
// > a = `first; second`
}
+9 -9
View File
@@ -13,11 +13,14 @@ import (
"code.gitea.io/gitea/modules/options"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation/i18n"
"code.gitea.io/gitea/modules/watcher"
"golang.org/x/text/language"
)
type contextKey struct{}
var ContextKey interface{} = &contextKey{}
// Locale represents an interface to translation
type Locale interface {
Language() string
@@ -54,7 +57,7 @@ func InitLocales(ctx context.Context) {
refreshLocales := func() {
i18n.ResetDefaultLocales()
localeNames, err := options.Dir("locale")
localeNames, err := options.AssetFS().ListFiles("locale", true)
if err != nil {
log.Fatal("Failed to list locale files: %v", err)
}
@@ -114,13 +117,10 @@ func InitLocales(ctx context.Context) {
})
if !setting.IsProd {
watcher.CreateWatcher(ctx, "Locales", &watcher.CreateWatcherOpts{
PathsCallback: options.WalkLocales,
BetweenCallback: func() {
lock.Lock()
defer lock.Unlock()
refreshLocales()
},
go options.AssetFS().WatchLocalChanges(ctx, func() {
lock.Lock()
defer lock.Unlock()
refreshLocales()
})
}
}
+9 -10
View File
@@ -74,29 +74,28 @@ const pathSeparator = string(os.PathSeparator)
//
// {`/foo`, ``, `bar`} => `/foo/bar`
// {`/foo`, `..`, `bar`} => `/foo/bar`
func FilePathJoinAbs(elem ...string) string {
elems := make([]string, len(elem))
func FilePathJoinAbs(base string, sub ...string) string {
elems := make([]string, 1, len(sub)+1)
// POISX filesystem can have `\` in file names. Windows: `\` and `/` are both used for path separators
// POSIX filesystem can have `\` in file names. Windows: `\` and `/` are both used for path separators
// to keep the behavior consistent, we do not allow `\` in file names, replace all `\` with `/`
if isOSWindows() {
elems[0] = filepath.Clean(elem[0])
elems[0] = filepath.Clean(base)
} else {
elems[0] = filepath.Clean(strings.ReplaceAll(elem[0], "\\", pathSeparator))
elems[0] = filepath.Clean(strings.ReplaceAll(base, "\\", pathSeparator))
}
if !filepath.IsAbs(elems[0]) {
// This shouldn't happen. If there is really necessary to pass in relative path, return the full path with filepath.Abs() instead
panic(fmt.Sprintf("FilePathJoinAbs: %q (for path %v) is not absolute, do not guess a relative path based on current working directory", elems[0], elems))
}
for i := 1; i < len(elem); i++ {
if elem[i] == "" {
for _, s := range sub {
if s == "" {
continue
}
if isOSWindows() {
elems[i] = filepath.Clean(pathSeparator + elem[i])
elems = append(elems, filepath.Clean(pathSeparator+s))
} else {
elems[i] = filepath.Clean(pathSeparator + strings.ReplaceAll(elem[i], "\\", pathSeparator))
elems = append(elems, filepath.Clean(pathSeparator+strings.ReplaceAll(s, "\\", pathSeparator)))
}
}
// the elems[0] must be an absolute path, just join them together
+1 -1
View File
@@ -207,6 +207,6 @@ func TestCleanPath(t *testing.T) {
}
}
for _, c := range cases {
assert.Equal(t, c.expected, FilePathJoinAbs(c.elems...), "case: %v", c.elems)
assert.Equal(t, c.expected, FilePathJoinAbs(c.elems[0], c.elems[1:]...), "case: %v", c.elems)
}
}
+28
View File
@@ -4,6 +4,7 @@
package util
import (
"sync"
"time"
)
@@ -18,3 +19,30 @@ func StopTimer(t *time.Timer) bool {
}
return stopped
}
func Debounce(d time.Duration) func(f func()) {
type debouncer struct {
mu sync.Mutex
t *time.Timer
}
db := &debouncer{}
return func(f func()) {
db.mu.Lock()
defer db.mu.Unlock()
if db.t != nil {
db.t.Stop()
}
var trigger *time.Timer
trigger = time.AfterFunc(d, func() {
db.mu.Lock()
defer db.mu.Unlock()
if trigger == db.t {
f()
db.t = nil
}
})
db.t = trigger
}
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDebounce(t *testing.T) {
var c int64
d := Debounce(50 * time.Millisecond)
d(func() { atomic.AddInt64(&c, 1) })
assert.EqualValues(t, 0, atomic.LoadInt64(&c))
d(func() { atomic.AddInt64(&c, 1) })
d(func() { atomic.AddInt64(&c, 1) })
time.Sleep(100 * time.Millisecond)
assert.EqualValues(t, 1, atomic.LoadInt64(&c))
d(func() { atomic.AddInt64(&c, 1) })
assert.EqualValues(t, 1, atomic.LoadInt64(&c))
d(func() { atomic.AddInt64(&c, 1) })
d(func() { atomic.AddInt64(&c, 1) })
d(func() { atomic.AddInt64(&c, 1) })
time.Sleep(100 * time.Millisecond)
assert.EqualValues(t, 2, atomic.LoadInt64(&c))
}
-24
View File
@@ -35,27 +35,3 @@ func SplitStringAtByteN(input string, n int) (left, right string) {
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
}
// SplitStringAtRuneN splits a string at rune n accounting for rune boundaries. (Combining characters are not accounted for.)
func SplitStringAtRuneN(input string, n int) (left, right string) {
if !utf8.ValidString(input) {
if len(input) <= n || n-3 < 0 {
return input, ""
}
return input[:n-3] + asciiEllipsis, asciiEllipsis + input[n-3:]
}
if utf8.RuneCountInString(input) <= n {
return input, ""
}
count := 0
end := 0
for count < n-1 {
_, size := utf8.DecodeRuneInString(input[end:])
end += size
count++
}
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
}
-14
View File
@@ -43,18 +43,4 @@ func TestSplitString(t *testing.T) {
{"\xef\x03", 1, "\xef\x03", ""},
}
test(tc, SplitStringAtByteN)
tc = []*testCase{
{"abc123xyz", 0, "", utf8Ellipsis},
{"abc123xyz", 1, "", utf8Ellipsis},
{"abc123xyz", 4, "abc", utf8Ellipsis},
{"啊bc123xyz", 4, "啊bc", utf8Ellipsis},
{"啊bc123xyz", 6, "啊bc12", utf8Ellipsis},
{"啊bc", 3, "啊bc", ""},
{"啊bc", 4, "啊bc", ""},
{"abc\xef\x03\xfe", 3, "", asciiEllipsis},
{"abc\xef\x03\xfe", 4, "a", asciiEllipsis},
{"\xef\x03", 1, "\xef\x03", ""},
}
test(tc, SplitStringAtRuneN)
}
+75 -68
View File
@@ -6,9 +6,8 @@ package util
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"math/big"
"regexp"
"strconv"
"strings"
@@ -117,29 +116,6 @@ func NormalizeEOL(input []byte) []byte {
return tmp[:pos]
}
// MergeInto merges pairs of values into a "dict"
func MergeInto(dict map[string]interface{}, values ...interface{}) (map[string]interface{}, error) {
for i := 0; i < len(values); i++ {
switch key := values[i].(type) {
case string:
i++
if i == len(values) {
return nil, errors.New("specify the key for non array values")
}
dict[key] = values[i]
case map[string]interface{}:
m := values[i].(map[string]interface{})
for i, v := range m {
dict[i] = v
}
default:
return nil, errors.New("dict values must be maps")
}
}
return dict, nil
}
// CryptoRandomInt returns a crypto random integer between 0 and limit, inclusive
func CryptoRandomInt(limit int64) (int64, error) {
rInt, err := rand.Int(rand.Reader, big.NewInt(limit))
@@ -185,55 +161,20 @@ func ToUpperASCII(s string) string {
return string(b)
}
var (
titleCaser = cases.Title(language.English)
titleCaserNoLower = cases.Title(language.English, cases.NoLower)
)
// ToTitleCase returns s with all english words capitalized
func ToTitleCase(s string) string {
return titleCaser.String(s)
// `cases.Title` is not thread-safe, do not use global shared variable for it
return cases.Title(language.English).String(s)
}
// ToTitleCaseNoLower returns s with all english words capitalized without lowercasing
// ToTitleCaseNoLower returns s with all english words capitalized without lower-casing
func ToTitleCaseNoLower(s string) string {
return titleCaserNoLower.String(s)
// `cases.Title` is not thread-safe, do not use global shared variable for it
return cases.Title(language.English, cases.NoLower).String(s)
}
var (
whitespaceOnly = regexp.MustCompile("(?m)^[ \t]+$")
leadingWhitespace = regexp.MustCompile("(?m)(^[ \t]*)(?:[^ \t\n])")
)
// Dedent removes common indentation of a multi-line string along with whitespace around it
// Based on https://github.com/lithammer/dedent
func Dedent(s string) string {
var margin string
s = whitespaceOnly.ReplaceAllString(s, "")
indents := leadingWhitespace.FindAllStringSubmatch(s, -1)
for i, indent := range indents {
if i == 0 {
margin = indent[1]
} else if strings.HasPrefix(indent[1], margin) {
continue
} else if strings.HasPrefix(margin, indent[1]) {
margin = indent[1]
} else {
margin = ""
break
}
}
if margin != "" {
s = regexp.MustCompile("(?m)^"+margin).ReplaceAllString(s, "")
}
return strings.TrimSpace(s)
}
// NumberIntoInt64 transform a given int into int64.
func NumberIntoInt64(number interface{}) int64 {
// ToInt64 transform a given int into int64.
func ToInt64(number interface{}) (int64, error) {
var value int64
switch v := number.(type) {
case int:
@@ -246,6 +187,72 @@ func NumberIntoInt64(number interface{}) int64 {
value = int64(v)
case int64:
value = v
case uint:
value = int64(v)
case uint8:
value = int64(v)
case uint16:
value = int64(v)
case uint32:
value = int64(v)
case uint64:
value = int64(v)
case float32:
value = int64(v)
case float64:
value = int64(v)
case string:
var err error
if value, err = strconv.ParseInt(v, 10, 64); err != nil {
return 0, err
}
default:
return 0, fmt.Errorf("unable to convert %v to int64", number)
}
return value
return value, nil
}
// ToFloat64 transform a given int into float64.
func ToFloat64(number interface{}) (float64, error) {
var value float64
switch v := number.(type) {
case int:
value = float64(v)
case int8:
value = float64(v)
case int16:
value = float64(v)
case int32:
value = float64(v)
case int64:
value = float64(v)
case uint:
value = float64(v)
case uint8:
value = float64(v)
case uint16:
value = float64(v)
case uint32:
value = float64(v)
case uint64:
value = float64(v)
case float32:
value = float64(v)
case float64:
value = v
case string:
var err error
if value, err = strconv.ParseFloat(v, 64); err != nil {
return 0, err
}
default:
return 0, fmt.Errorf("unable to convert %v to float64", number)
}
return value, nil
}
-7
View File
@@ -224,10 +224,3 @@ func TestToTitleCase(t *testing.T) {
assert.Equal(t, ToTitleCase(`foo bar baz`), `Foo Bar Baz`)
assert.Equal(t, ToTitleCase(`FOO BAR BAZ`), `Foo Bar Baz`)
}
func TestDedent(t *testing.T) {
assert.Equal(t, Dedent(`
foo
bar
`), "foo\n\tbar")
}
-114
View File
@@ -1,114 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package watcher
import (
"context"
"io/fs"
"os"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
"github.com/fsnotify/fsnotify"
)
// CreateWatcherOpts are options to configure the watcher
type CreateWatcherOpts struct {
// PathsCallback is used to set the required paths to watch
PathsCallback func(func(path, name string, d fs.DirEntry, err error) error) error
// BeforeCallback is called before any files are watched
BeforeCallback func()
// Between Callback is called between after a watched event has occurred
BetweenCallback func()
// AfterCallback is called as this watcher ends
AfterCallback func()
}
// CreateWatcher creates a watcher labelled with the provided description and running with the provided options.
// The created watcher will create a subcontext from the provided ctx and register it with the process manager.
func CreateWatcher(ctx context.Context, desc string, opts *CreateWatcherOpts) {
go run(ctx, desc, opts)
}
func run(ctx context.Context, desc string, opts *CreateWatcherOpts) {
if opts.BeforeCallback != nil {
opts.BeforeCallback()
}
if opts.AfterCallback != nil {
defer opts.AfterCallback()
}
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Watcher: "+desc, process.SystemProcessType, true)
defer finished()
log.Trace("Watcher loop starting for %s", desc)
defer log.Trace("Watcher loop ended for %s", desc)
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Error("Unable to create watcher for %s: %v", desc, err)
return
}
if err := opts.PathsCallback(func(path, _ string, d fs.DirEntry, err error) error {
if err != nil && !os.IsNotExist(err) {
return err
}
log.Trace("Watcher: %s watching %q", desc, path)
_ = watcher.Add(path)
return nil
}); err != nil {
log.Error("Unable to create watcher for %s: %v", desc, err)
_ = watcher.Close()
return
}
// Note we don't call the BetweenCallback here
for {
select {
case event, ok := <-watcher.Events:
if !ok {
_ = watcher.Close()
return
}
log.Debug("Watched file for %s had event: %v", desc, event)
case err, ok := <-watcher.Errors:
if !ok {
_ = watcher.Close()
return
}
log.Error("Error whilst watching files for %s: %v", desc, err)
case <-ctx.Done():
_ = watcher.Close()
return
}
// Recreate the watcher - only call the BetweenCallback after the new watcher is set-up
_ = watcher.Close()
watcher, err = fsnotify.NewWatcher()
if err != nil {
log.Error("Unable to create watcher for %s: %v", desc, err)
return
}
if err := opts.PathsCallback(func(path, _ string, _ fs.DirEntry, err error) error {
if err != nil {
return err
}
_ = watcher.Add(path)
return nil
}); err != nil {
log.Error("Unable to create watcher for %s: %v", desc, err)
_ = watcher.Close()
return
}
// Inform our BetweenCallback that there has been an event
if opts.BetweenCallback != nil {
opts.BetweenCallback()
}
}
}
+26 -166
View File
@@ -7,184 +7,23 @@ package middleware
import (
"net/http"
"net/url"
"time"
"strings"
"code.gitea.io/gitea/modules/setting"
)
// MaxAge sets the maximum age for a provided cookie
func MaxAge(maxAge int) func(*http.Cookie) {
return func(c *http.Cookie) {
c.MaxAge = maxAge
}
}
// Path sets the path for a provided cookie
func Path(path string) func(*http.Cookie) {
return func(c *http.Cookie) {
c.Path = path
}
}
// Domain sets the domain for a provided cookie
func Domain(domain string) func(*http.Cookie) {
return func(c *http.Cookie) {
c.Domain = domain
}
}
// Secure sets the secure setting for a provided cookie
func Secure(secure bool) func(*http.Cookie) {
return func(c *http.Cookie) {
c.Secure = secure
}
}
// HTTPOnly sets the HttpOnly setting for a provided cookie
func HTTPOnly(httpOnly bool) func(*http.Cookie) {
return func(c *http.Cookie) {
c.HttpOnly = httpOnly
}
}
// Expires sets the expires and rawexpires for a provided cookie
func Expires(expires time.Time) func(*http.Cookie) {
return func(c *http.Cookie) {
c.Expires = expires
c.RawExpires = expires.Format(time.UnixDate)
}
}
// SameSite sets the SameSite for a provided cookie
func SameSite(sameSite http.SameSite) func(*http.Cookie) {
return func(c *http.Cookie) {
c.SameSite = sameSite
}
}
// NewCookie creates a cookie
func NewCookie(name, value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
HttpOnly: true,
Path: setting.SessionConfig.CookiePath,
Domain: setting.SessionConfig.Domain,
MaxAge: maxAge,
Secure: setting.SessionConfig.Secure,
}
}
// SetRedirectToCookie convenience function to set the RedirectTo cookie consistently
func SetRedirectToCookie(resp http.ResponseWriter, value string) {
SetCookie(resp, "redirect_to", value,
0,
setting.AppSubURL,
"",
setting.SessionConfig.Secure,
true,
SameSite(setting.SessionConfig.SameSite))
SetSiteCookie(resp, "redirect_to", value, 0)
}
// DeleteRedirectToCookie convenience function to delete most cookies consistently
func DeleteRedirectToCookie(resp http.ResponseWriter) {
SetCookie(resp, "redirect_to", "",
-1,
setting.AppSubURL,
"",
setting.SessionConfig.Secure,
true,
SameSite(setting.SessionConfig.SameSite))
SetSiteCookie(resp, "redirect_to", "", -1)
}
// DeleteCSRFCookie convenience function to delete SessionConfigPath cookies consistently
func DeleteCSRFCookie(resp http.ResponseWriter) {
SetCookie(resp, setting.CSRFCookieName, "",
-1,
setting.SessionConfig.CookiePath,
setting.SessionConfig.Domain) // FIXME: Do we need to set the Secure, httpOnly and SameSite values too?
}
// SetCookie set the cookies. (name, value, lifetime, path, domain, secure, httponly, expires, {sameSite, ...})
// TODO: Copied from gitea.com/macaron/macaron and should be improved after macaron removed.
func SetCookie(resp http.ResponseWriter, name, value string, others ...interface{}) {
cookie := http.Cookie{}
cookie.Name = name
cookie.Value = url.QueryEscape(value)
if len(others) > 0 {
switch v := others[0].(type) {
case int:
cookie.MaxAge = v
case int64:
cookie.MaxAge = int(v)
case int32:
cookie.MaxAge = int(v)
case func(*http.Cookie):
v(&cookie)
}
}
cookie.Path = "/"
if len(others) > 1 {
if v, ok := others[1].(string); ok && len(v) > 0 {
cookie.Path = v
} else if v, ok := others[1].(func(*http.Cookie)); ok {
v(&cookie)
}
}
if len(others) > 2 {
if v, ok := others[2].(string); ok && len(v) > 0 {
cookie.Domain = v
} else if v, ok := others[2].(func(*http.Cookie)); ok {
v(&cookie)
}
}
if len(others) > 3 {
switch v := others[3].(type) {
case bool:
cookie.Secure = v
case func(*http.Cookie):
v(&cookie)
default:
if others[3] != nil {
cookie.Secure = true
}
}
}
if len(others) > 4 {
if v, ok := others[4].(bool); ok && v {
cookie.HttpOnly = true
} else if v, ok := others[4].(func(*http.Cookie)); ok {
v(&cookie)
}
}
if len(others) > 5 {
if v, ok := others[5].(time.Time); ok {
cookie.Expires = v
cookie.RawExpires = v.Format(time.UnixDate)
} else if v, ok := others[5].(func(*http.Cookie)); ok {
v(&cookie)
}
}
if len(others) > 6 {
for _, other := range others[6:] {
if v, ok := other.(func(*http.Cookie)); ok {
v(&cookie)
}
}
}
resp.Header().Add("Set-Cookie", cookie.String())
}
// GetCookie returns given cookie value from request header.
func GetCookie(req *http.Request, name string) string {
// GetSiteCookie returns given cookie value from request header.
func GetSiteCookie(req *http.Request, name string) string {
cookie, err := req.Cookie(name)
if err != nil {
return ""
@@ -192,3 +31,24 @@ func GetCookie(req *http.Request, name string) string {
val, _ := url.QueryUnescape(cookie.Value)
return val
}
// SetSiteCookie returns given cookie value from request header.
func SetSiteCookie(resp http.ResponseWriter, name, value string, maxAge int) {
cookie := &http.Cookie{
Name: name,
Value: url.QueryEscape(value),
MaxAge: maxAge,
Path: setting.SessionConfig.CookiePath,
Domain: setting.SessionConfig.Domain,
Secure: setting.SessionConfig.Secure,
HttpOnly: true,
SameSite: setting.SessionConfig.SameSite,
}
resp.Header().Add("Set-Cookie", cookie.String())
if maxAge < 0 {
// There was a bug in "setting.SessionConfig.CookiePath" code, the old default value of it was empty "".
// So we have to delete the cookie on path="" again, because some old code leaves cookies on path="".
cookie.Path = strings.TrimSuffix(setting.SessionConfig.CookiePath, "/")
resp.Header().Add("Set-Cookie", cookie.String())
}
}
+3 -15
View File
@@ -6,7 +6,6 @@ package middleware
import (
"net/http"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/translation/i18n"
@@ -49,23 +48,12 @@ func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
}
// SetLocaleCookie convenience function to set the locale cookie consistently
func SetLocaleCookie(resp http.ResponseWriter, lang string, expiry int) {
SetCookie(resp, "lang", lang, expiry,
setting.AppSubURL,
setting.SessionConfig.Domain,
setting.SessionConfig.Secure,
true,
SameSite(setting.SessionConfig.SameSite))
func SetLocaleCookie(resp http.ResponseWriter, lang string, maxAge int) {
SetSiteCookie(resp, "lang", lang, maxAge)
}
// DeleteLocaleCookie convenience function to delete the locale cookie consistently
// Setting the lang cookie will trigger the middleware to reset the language to previous state.
func DeleteLocaleCookie(resp http.ResponseWriter) {
SetCookie(resp, "lang", "",
-1,
setting.AppSubURL,
setting.SessionConfig.Domain,
setting.SessionConfig.Secure,
true,
SameSite(setting.SessionConfig.SameSite))
SetSiteCookie(resp, "lang", "", -1)
}