Skip to content

Commit

Permalink
Use fetch to send requests to create issues/comments (#25258)
Browse files Browse the repository at this point in the history
Follow #23290

Network error won't make content lost. And this is a much better
approach than "loading-button".

The UI is not perfect and there are still some TODOs, they can be done
in following PRs, not a must in this PR's scope.

<details>


![image](https://github.com/go-gitea/gitea/assets/2114189/c94ba958-aa46-4747-8ddf-6584deeed25c)

</details>
  • Loading branch information
wxiaoguang authored Jun 16, 2023
1 parent a305c37 commit b71cb7a
Show file tree
Hide file tree
Showing 14 changed files with 163 additions and 54 deletions.
4 changes: 4 additions & 0 deletions modules/context/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ func (b *Base) JSONRedirect(redirect string) {
b.JSON(http.StatusOK, map[string]any{"redirect": redirect})
}

func (b *Base) JSONError(msg string) {
b.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg})
}

// RemoteAddr returns the client machine ip address
func (b *Base) RemoteAddr() string {
return b.Req.RemoteAddr
Expand Down
10 changes: 2 additions & 8 deletions modules/context/context_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
Expand Down Expand Up @@ -49,14 +50,7 @@ func (ctx *Context) RedirectToFirst(location ...string) {
continue
}

// Unfortunately browsers consider a redirect Location with preceding "//", "\\" and "/\" as meaning redirect to "http(s)://REST_OF_PATH"
// Therefore we should ignore these redirect locations to prevent open redirects
if len(loc) > 1 && (loc[0] == '/' || loc[0] == '\\') && (loc[1] == '/' || loc[1] == '\\') {
continue
}

u, err := url.Parse(loc)
if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(loc), strings.ToLower(setting.AppURL))) {
if httplib.IsRiskyRedirectURL(loc) {
continue
}

Expand Down
27 changes: 27 additions & 0 deletions modules/httplib/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httplib

import (
"net/url"
"strings"

"code.gitea.io/gitea/modules/setting"
)

// IsRiskyRedirectURL returns true if the URL is considered risky for redirects
func IsRiskyRedirectURL(s string) bool {
// Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
// Therefore we should ignore these redirect locations to prevent open redirects
if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') {
return true
}

u, err := url.Parse(s)
if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(s), strings.ToLower(setting.AppURL))) {
return true
}

return false
}
38 changes: 38 additions & 0 deletions modules/httplib/url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httplib

import (
"testing"

"code.gitea.io/gitea/modules/setting"

"github.com/stretchr/testify/assert"
)

func TestIsRiskyRedirectURL(t *testing.T) {
setting.AppURL = "http://localhost:3000/"
tests := []struct {
input string
want bool
}{
{"", false},
{"foo", false},
{"/", false},
{"/foo?k=%20#abc", false},

{"//", true},
{"\\\\", true},
{"/\\", true},
{"\\/", true},
{"mail:a@b.com", true},
{"https://test.com", true},
{setting.AppURL + "/foo", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.want, IsRiskyRedirectURL(tt.input))
})
}
}
19 changes: 18 additions & 1 deletion modules/test/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,29 @@ package test

import (
"net/http"
"net/http/httptest"
"strings"

"code.gitea.io/gitea/modules/json"
)

// RedirectURL returns the redirect URL of a http response.
// It also works for JSONRedirect: `{"redirect": "..."}`
func RedirectURL(resp http.ResponseWriter) string {
return resp.Header().Get("Location")
loc := resp.Header().Get("Location")
if loc != "" {
return loc
}
if r, ok := resp.(*httptest.ResponseRecorder); ok {
m := map[string]any{}
err := json.Unmarshal(r.Body.Bytes(), &m)
if err == nil {
if loc, ok := m["redirect"].(string); ok {
return loc
}
}
}
return ""
}

func IsNormalPageCompleted(s string) bool {
Expand Down
26 changes: 26 additions & 0 deletions routers/common/redirect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package common

import (
"net/http"

"code.gitea.io/gitea/modules/httplib"
)

// FetchRedirectDelegate helps the "fetch" requests to redirect to the correct location
func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
// When use "fetch" to post requests and the response is a redirect, browser's "location.href = uri" has limitations.
// 1. change "location" from old "/foo" to new "/foo#hash", the browser will not reload the page.
// 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target.
// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
// then frontend needs this delegate to redirect to the new location with hash correctly.
redirect := req.PostFormValue("redirect")
if httplib.IsRiskyRedirectURL(redirect) {
resp.WriteHeader(http.StatusBadRequest)
return
}
resp.Header().Add("Location", redirect)
resp.WriteHeader(http.StatusSeeOther)
}
2 changes: 2 additions & 0 deletions routers/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ func NormalRoutes(ctx context.Context) *web.Route {
r.Mount("/api/v1", apiv1.Routes(ctx))
r.Mount("/api/internal", private.Routes())

r.Post("/-/fetch-redirect", common.FetchRedirectDelegate)

if setting.Packages.Enabled {
// This implements package support for most package managers
r.Mount("/api/packages", packages_router.CommonRoutes(ctx))
Expand Down
31 changes: 12 additions & 19 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -1134,12 +1134,12 @@ func NewIssuePost(ctx *context.Context) {
}

if ctx.HasError() {
ctx.HTML(http.StatusOK, tplIssueNew)
ctx.JSONError(ctx.GetErrMsg())
return
}

if util.IsEmptyString(form.Title) {
ctx.RenderWithErr(ctx.Tr("repo.issues.new.title_empty"), tplIssueNew, form)
ctx.JSONError(ctx.Tr("repo.issues.new.title_empty"))
return
}

Expand Down Expand Up @@ -1184,9 +1184,9 @@ func NewIssuePost(ctx *context.Context) {

log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 {
ctx.Redirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10))
ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10))
} else {
ctx.Redirect(issue.Link())
ctx.JSONRedirect(issue.Link())
}
}

Expand Down Expand Up @@ -2777,8 +2777,7 @@ func NewComment(ctx *context.Context) {
}

if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked"))
ctx.Redirect(issue.Link())
ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked"))
return
}

Expand All @@ -2788,8 +2787,7 @@ func NewComment(ctx *context.Context) {
}

if ctx.HasError() {
ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
ctx.Redirect(issue.Link())
ctx.JSONError(ctx.GetErrMsg())
return
}

Expand All @@ -2809,8 +2807,7 @@ func NewComment(ctx *context.Context) {
pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow)
if err != nil {
if !issues_model.IsErrPullRequestNotExist(err) {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index))
ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
return
}
}
Expand Down Expand Up @@ -2841,8 +2838,7 @@ func NewComment(ctx *context.Context) {
}
if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.BaseBranch); !ok {
// todo localize
ctx.Flash.Error("The origin branch is delete, cannot reopen.")
ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index))
ctx.JSONError("The origin branch is delete, cannot reopen.")
return
}
headBranchRef := pull.GetGitHeadBranchRefName()
Expand Down Expand Up @@ -2882,11 +2878,9 @@ func NewComment(ctx *context.Context) {

if issues_model.IsErrDependenciesLeft(err) {
if issue.IsPull {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index))
ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
} else {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked"))
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index))
ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked"))
}
return
}
Expand All @@ -2899,7 +2893,6 @@ func NewComment(ctx *context.Context) {
log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed)
}
}

}

// Redirect to comment hashtag if there is any actual content.
Expand All @@ -2908,9 +2901,9 @@ func NewComment(ctx *context.Context) {
typeName = "pulls"
}
if comment != nil {
ctx.Redirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag()))
ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag()))
} else {
ctx.Redirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index))
ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index))
}
}()

Expand Down
4 changes: 2 additions & 2 deletions templates/repo/issue/new_form.tmpl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<form class="issue-content ui comment form" id="new-issue" action="{{.Link}}" method="post">
<form class="issue-content ui comment form form-fetch-action" id="new-issue" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
{{if .Flash}}
<div class="sixteen wide column">
Expand Down Expand Up @@ -35,7 +35,7 @@
{{template "repo/issue/comment_tab" .}}
{{end}}
<div class="text right">
<button class="ui green button loading-button" tabindex="6">
<button class="ui green button" tabindex="6">
{{if .PageIsComparePull}}
{{.locale.Tr "repo.pulls.create"}}
{{else}}
Expand Down
9 changes: 4 additions & 5 deletions templates/repo/issue/view_content.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -96,28 +96,27 @@
{{avatar $.Context .SignedUser 40}}
</a>
<div class="content">
<form class="ui segment form" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post">
<form class="ui segment form form-fetch-action" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post">
{{template "repo/issue/comment_tab" .}}
{{.CsrfTokenHtml}}
<input id="status" name="status" type="hidden">
<div class="field footer">
<div class="text right">
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .DisableStatusChange)}}
{{if .Issue.IsClosed}}
<button id="status-button" class="ui green basic button" tabindex="6" data-status="{{.locale.Tr "repo.issues.reopen_issue"}}" data-status-and-comment="{{.locale.Tr "repo.issues.reopen_comment_issue"}}" data-status-val="reopen">
<button id="status-button" class="ui green basic button" tabindex="6" data-status="{{.locale.Tr "repo.issues.reopen_issue"}}" data-status-and-comment="{{.locale.Tr "repo.issues.reopen_comment_issue"}}" name="status" value="reopen">
{{.locale.Tr "repo.issues.reopen_issue"}}
</button>
{{else}}
{{$closeTranslationKey := "repo.issues.close"}}
{{if .Issue.IsPull}}
{{$closeTranslationKey = "repo.pulls.close"}}
{{end}}
<button id="status-button" class="ui red basic button" tabindex="6" data-status="{{.locale.Tr $closeTranslationKey}}" data-status-and-comment="{{.locale.Tr "repo.issues.close_comment_issue"}}" data-status-val="close">
<button id="status-button" class="ui red basic button" tabindex="6" data-status="{{.locale.Tr $closeTranslationKey}}" data-status-and-comment="{{.locale.Tr "repo.issues.close_comment_issue"}}" name="status" value="close">
{{.locale.Tr $closeTranslationKey}}
</button>
{{end}}
{{end}}
<button class="ui green button loading-button" tabindex="5">
<button class="ui green button" tabindex="5">
{{.locale.Tr "repo.issues.create_comment"}}
</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func TestCreateIssueAttachment(t *testing.T) {
}

req = NewRequestWithValues(t, "POST", link, postData)
resp = session.MakeRequest(t, req, http.StatusSeeOther)
resp = session.MakeRequest(t, req, http.StatusOK)
test.RedirectURL(resp) // check that redirect URL exists

// Validate that attachment is available
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content
"title": title,
"content": content,
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
resp = session.MakeRequest(t, req, http.StatusOK)

issueURL := test.RedirectURL(resp)
req = NewRequest(t, "GET", issueURL)
Expand Down Expand Up @@ -165,7 +165,7 @@ func testIssueAddComment(t *testing.T, session *TestSession, issueURL, content,
"content": content,
"status": status,
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
resp = session.MakeRequest(t, req, http.StatusOK)

req = NewRequest(t, "GET", test.RedirectURL(resp))
resp = session.MakeRequest(t, req, http.StatusOK)
Expand Down
Loading

0 comments on commit b71cb7a

Please sign in to comment.