mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-03 02:33:29 +02:00
Merge remote-tracking branch 'upstream/main' into limit-repo-size
This commit is contained in:
+390
-76
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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&b=2#comment-123" class="ref-issue">#4</a> test`)
|
||||
`<a href="http://localhost:3000/gogits/gogs/issues/4?a=1&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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !bindata
|
||||
|
||||
package setting
|
||||
|
||||
const HasBuiltinBindata = false
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build bindata
|
||||
|
||||
package setting
|
||||
|
||||
const HasBuiltinBindata = true
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user