mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 10:56:10 +01:00 
			
		
		
		
	Use fetch to send requests to create issues/comments (#25258)
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>  </details>
This commit is contained in:
		| @@ -136,6 +136,10 @@ func (b *Base) JSONRedirect(redirect string) { | |||||||
| 	b.JSON(http.StatusOK, map[string]any{"redirect": redirect}) | 	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 | // RemoteAddr returns the client machine ip address | ||||||
| func (b *Base) RemoteAddr() string { | func (b *Base) RemoteAddr() string { | ||||||
| 	return b.Req.RemoteAddr | 	return b.Req.RemoteAddr | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ import ( | |||||||
|  |  | ||||||
| 	user_model "code.gitea.io/gitea/models/user" | 	user_model "code.gitea.io/gitea/models/user" | ||||||
| 	"code.gitea.io/gitea/modules/base" | 	"code.gitea.io/gitea/modules/base" | ||||||
|  | 	"code.gitea.io/gitea/modules/httplib" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	"code.gitea.io/gitea/modules/setting" | 	"code.gitea.io/gitea/modules/setting" | ||||||
| 	"code.gitea.io/gitea/modules/templates" | 	"code.gitea.io/gitea/modules/templates" | ||||||
| @@ -49,14 +50,7 @@ func (ctx *Context) RedirectToFirst(location ...string) { | |||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		// Unfortunately browsers consider a redirect Location with preceding "//", "\\" and "/\" as meaning redirect to "http(s)://REST_OF_PATH" | 		if httplib.IsRiskyRedirectURL(loc) { | ||||||
| 		// 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))) { |  | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								modules/httplib/url.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								modules/httplib/url.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								modules/httplib/url_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								modules/httplib/url_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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)) | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -5,12 +5,29 @@ package test | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"net/http" | 	"net/http" | ||||||
|  | 	"net/http/httptest" | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/json" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // RedirectURL returns the redirect URL of a http response. | // RedirectURL returns the redirect URL of a http response. | ||||||
|  | // It also works for JSONRedirect: `{"redirect": "..."}` | ||||||
| func RedirectURL(resp http.ResponseWriter) string { | 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 { | func IsNormalPageCompleted(s string) bool { | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								routers/common/redirect.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								routers/common/redirect.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | } | ||||||
| @@ -183,6 +183,8 @@ func NormalRoutes(ctx context.Context) *web.Route { | |||||||
| 	r.Mount("/api/v1", apiv1.Routes(ctx)) | 	r.Mount("/api/v1", apiv1.Routes(ctx)) | ||||||
| 	r.Mount("/api/internal", private.Routes()) | 	r.Mount("/api/internal", private.Routes()) | ||||||
|  |  | ||||||
|  | 	r.Post("/-/fetch-redirect", common.FetchRedirectDelegate) | ||||||
|  |  | ||||||
| 	if setting.Packages.Enabled { | 	if setting.Packages.Enabled { | ||||||
| 		// This implements package support for most package managers | 		// This implements package support for most package managers | ||||||
| 		r.Mount("/api/packages", packages_router.CommonRoutes(ctx)) | 		r.Mount("/api/packages", packages_router.CommonRoutes(ctx)) | ||||||
|   | |||||||
| @@ -1134,12 +1134,12 @@ func NewIssuePost(ctx *context.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if ctx.HasError() { | 	if ctx.HasError() { | ||||||
| 		ctx.HTML(http.StatusOK, tplIssueNew) | 		ctx.JSONError(ctx.GetErrMsg()) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if util.IsEmptyString(form.Title) { | 	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 | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -1184,9 +1184,9 @@ func NewIssuePost(ctx *context.Context) { | |||||||
|  |  | ||||||
| 	log.Trace("Issue created: %d/%d", repo.ID, issue.ID) | 	log.Trace("Issue created: %d/%d", repo.ID, issue.ID) | ||||||
| 	if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { | 	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 { | 	} else { | ||||||
| 		ctx.Redirect(issue.Link()) | 		ctx.JSONRedirect(issue.Link()) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -2777,8 +2777,7 @@ func NewComment(ctx *context.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { | 	if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { | ||||||
| 		ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) | 		ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked")) | ||||||
| 		ctx.Redirect(issue.Link()) |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -2788,8 +2787,7 @@ func NewComment(ctx *context.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if ctx.HasError() { | 	if ctx.HasError() { | ||||||
| 		ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) | 		ctx.JSONError(ctx.GetErrMsg()) | ||||||
| 		ctx.Redirect(issue.Link()) |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -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) | 				pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow) | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 					if !issues_model.IsErrPullRequestNotExist(err) { | 					if !issues_model.IsErrPullRequestNotExist(err) { | ||||||
| 						ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) | 						ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) | ||||||
| 						ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index)) |  | ||||||
| 						return | 						return | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| @@ -2841,8 +2838,7 @@ func NewComment(ctx *context.Context) { | |||||||
| 				} | 				} | ||||||
| 				if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.BaseBranch); !ok { | 				if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.BaseBranch); !ok { | ||||||
| 					// todo localize | 					// todo localize | ||||||
| 					ctx.Flash.Error("The origin branch is delete, cannot reopen.") | 					ctx.JSONError("The origin branch is delete, cannot reopen.") | ||||||
| 					ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index)) |  | ||||||
| 					return | 					return | ||||||
| 				} | 				} | ||||||
| 				headBranchRef := pull.GetGitHeadBranchRefName() | 				headBranchRef := pull.GetGitHeadBranchRefName() | ||||||
| @@ -2882,11 +2878,9 @@ func NewComment(ctx *context.Context) { | |||||||
|  |  | ||||||
| 					if issues_model.IsErrDependenciesLeft(err) { | 					if issues_model.IsErrDependenciesLeft(err) { | ||||||
| 						if issue.IsPull { | 						if issue.IsPull { | ||||||
| 							ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) | 							ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) | ||||||
| 							ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) |  | ||||||
| 						} else { | 						} else { | ||||||
| 							ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked")) | 							ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked")) | ||||||
| 							ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index)) |  | ||||||
| 						} | 						} | ||||||
| 						return | 						return | ||||||
| 					} | 					} | ||||||
| @@ -2899,7 +2893,6 @@ func NewComment(ctx *context.Context) { | |||||||
| 					log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) | 					log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		// Redirect to comment hashtag if there is any actual content. | 		// Redirect to comment hashtag if there is any actual content. | ||||||
| @@ -2908,9 +2901,9 @@ func NewComment(ctx *context.Context) { | |||||||
| 			typeName = "pulls" | 			typeName = "pulls" | ||||||
| 		} | 		} | ||||||
| 		if comment != nil { | 		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 { | 		} 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)) | ||||||
| 		} | 		} | ||||||
| 	}() | 	}() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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}} | 	{{.CsrfTokenHtml}} | ||||||
| 	{{if .Flash}} | 	{{if .Flash}} | ||||||
| 		<div class="sixteen wide column"> | 		<div class="sixteen wide column"> | ||||||
| @@ -35,7 +35,7 @@ | |||||||
| 						{{template "repo/issue/comment_tab" .}} | 						{{template "repo/issue/comment_tab" .}} | ||||||
| 					{{end}} | 					{{end}} | ||||||
| 					<div class="text right"> | 					<div class="text right"> | ||||||
| 						<button class="ui green button loading-button" tabindex="6"> | 						<button class="ui green button" tabindex="6"> | ||||||
| 							{{if .PageIsComparePull}} | 							{{if .PageIsComparePull}} | ||||||
| 								{{.locale.Tr "repo.pulls.create"}} | 								{{.locale.Tr "repo.pulls.create"}} | ||||||
| 							{{else}} | 							{{else}} | ||||||
|   | |||||||
| @@ -96,15 +96,14 @@ | |||||||
| 						{{avatar $.Context .SignedUser 40}} | 						{{avatar $.Context .SignedUser 40}} | ||||||
| 					</a> | 					</a> | ||||||
| 					<div class="content"> | 					<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" .}} | 							{{template "repo/issue/comment_tab" .}} | ||||||
| 							{{.CsrfTokenHtml}} | 							{{.CsrfTokenHtml}} | ||||||
| 							<input id="status" name="status" type="hidden"> |  | ||||||
| 							<div class="field footer"> | 							<div class="field footer"> | ||||||
| 								<div class="text right"> | 								<div class="text right"> | ||||||
| 									{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .DisableStatusChange)}} | 									{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .DisableStatusChange)}} | ||||||
| 										{{if .Issue.IsClosed}} | 										{{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"}} | 												{{.locale.Tr "repo.issues.reopen_issue"}} | ||||||
| 											</button> | 											</button> | ||||||
| 										{{else}} | 										{{else}} | ||||||
| @@ -112,12 +111,12 @@ | |||||||
| 											{{if .Issue.IsPull}} | 											{{if .Issue.IsPull}} | ||||||
| 												{{$closeTranslationKey = "repo.pulls.close"}} | 												{{$closeTranslationKey = "repo.pulls.close"}} | ||||||
| 											{{end}} | 											{{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}} | 												{{.locale.Tr $closeTranslationKey}} | ||||||
| 											</button> | 											</button> | ||||||
| 										{{end}} | 										{{end}} | ||||||
| 									{{end}} | 									{{end}} | ||||||
| 									<button class="ui green button loading-button" tabindex="5"> | 									<button class="ui green button" tabindex="5"> | ||||||
| 										{{.locale.Tr "repo.issues.create_comment"}} | 										{{.locale.Tr "repo.issues.create_comment"}} | ||||||
| 									</button> | 									</button> | ||||||
| 								</div> | 								</div> | ||||||
|   | |||||||
| @@ -83,7 +83,7 @@ func TestCreateIssueAttachment(t *testing.T) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	req = NewRequestWithValues(t, "POST", link, postData) | 	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 | 	test.RedirectURL(resp) // check that redirect URL exists | ||||||
|  |  | ||||||
| 	// Validate that attachment is available | 	// Validate that attachment is available | ||||||
|   | |||||||
| @@ -135,7 +135,7 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content | |||||||
| 		"title":   title, | 		"title":   title, | ||||||
| 		"content": content, | 		"content": content, | ||||||
| 	}) | 	}) | ||||||
| 	resp = session.MakeRequest(t, req, http.StatusSeeOther) | 	resp = session.MakeRequest(t, req, http.StatusOK) | ||||||
|  |  | ||||||
| 	issueURL := test.RedirectURL(resp) | 	issueURL := test.RedirectURL(resp) | ||||||
| 	req = NewRequest(t, "GET", issueURL) | 	req = NewRequest(t, "GET", issueURL) | ||||||
| @@ -165,7 +165,7 @@ func testIssueAddComment(t *testing.T, session *TestSession, issueURL, content, | |||||||
| 		"content": content, | 		"content": content, | ||||||
| 		"status":  status, | 		"status":  status, | ||||||
| 	}) | 	}) | ||||||
| 	resp = session.MakeRequest(t, req, http.StatusSeeOther) | 	resp = session.MakeRequest(t, req, http.StatusOK) | ||||||
|  |  | ||||||
| 	req = NewRequest(t, "GET", test.RedirectURL(resp)) | 	req = NewRequest(t, "GET", test.RedirectURL(resp)) | ||||||
| 	resp = session.MakeRequest(t, req, http.StatusOK) | 	resp = session.MakeRequest(t, req, http.StatusOK) | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js'; | |||||||
| import {htmlEscape} from 'escape-goat'; | import {htmlEscape} from 'escape-goat'; | ||||||
| import {createTippy} from '../modules/tippy.js'; | import {createTippy} from '../modules/tippy.js'; | ||||||
|  |  | ||||||
| const {appUrl, csrfToken, i18n} = window.config; | const {appUrl, appSubUrl, csrfToken, i18n} = window.config; | ||||||
|  |  | ||||||
| export function initGlobalFormDirtyLeaveConfirm() { | export function initGlobalFormDirtyLeaveConfirm() { | ||||||
|   // Warn users that try to leave a page after entering data into a form. |   // Warn users that try to leave a page after entering data into a form. | ||||||
| @@ -61,6 +61,21 @@ export function initGlobalButtonClickOnEnter() { | |||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // doRedirect does real redirection to bypass the browser's limitations of "location" | ||||||
|  | // more details are in the backend's fetch-redirect handler | ||||||
|  | function doRedirect(redirect) { | ||||||
|  |   const form = document.createElement('form'); | ||||||
|  |   const input = document.createElement('input'); | ||||||
|  |   form.method = 'post'; | ||||||
|  |   form.action = `${appSubUrl}/-/fetch-redirect`; | ||||||
|  |   input.type = 'hidden'; | ||||||
|  |   input.name = 'redirect'; | ||||||
|  |   input.value = redirect; | ||||||
|  |   form.append(input); | ||||||
|  |   document.body.append(form); | ||||||
|  |   form.submit(); | ||||||
|  | } | ||||||
|  |  | ||||||
| async function formFetchAction(e) { | async function formFetchAction(e) { | ||||||
|   if (!e.target.classList.contains('form-fetch-action')) return; |   if (!e.target.classList.contains('form-fetch-action')) return; | ||||||
|  |  | ||||||
| @@ -101,6 +116,7 @@ async function formFetchAction(e) { | |||||||
|   const onError = (msg) => { |   const onError = (msg) => { | ||||||
|     formEl.classList.remove('is-loading', 'small-loading-icon'); |     formEl.classList.remove('is-loading', 'small-loading-icon'); | ||||||
|     if (errorTippy) errorTippy.destroy(); |     if (errorTippy) errorTippy.destroy(); | ||||||
|  |     // TODO: use a better toast UI instead of the tippy. If the form height is large, the tippy position is not good | ||||||
|     errorTippy = createTippy(formEl, { |     errorTippy = createTippy(formEl, { | ||||||
|       content: msg, |       content: msg, | ||||||
|       interactive: true, |       interactive: true, | ||||||
| @@ -120,15 +136,21 @@ async function formFetchAction(e) { | |||||||
|         const {redirect} = await resp.json(); |         const {redirect} = await resp.json(); | ||||||
|         formEl.classList.remove('dirty'); // remove the areYouSure check before reloading |         formEl.classList.remove('dirty'); // remove the areYouSure check before reloading | ||||||
|         if (redirect) { |         if (redirect) { | ||||||
|           window.location.href = redirect; |           doRedirect(redirect); | ||||||
|         } else { |         } else { | ||||||
|           window.location.reload(); |           window.location.reload(); | ||||||
|         } |         } | ||||||
|  |       } else if (resp.status >= 400 && resp.status < 500) { | ||||||
|  |         const data = await resp.json(); | ||||||
|  |         // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" | ||||||
|  |         // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. | ||||||
|  |         onError(data.errorMessage || `server error: ${resp.status}`); | ||||||
|       } else { |       } else { | ||||||
|         onError(`server error: ${resp.status}`); |         onError(`server error: ${resp.status}`); | ||||||
|       } |       } | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       onError(e.error); |       console.error('error when doRequest', e); | ||||||
|  |       onError(i18n.network_error); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -183,14 +205,6 @@ export function initGlobalCommon() { | |||||||
|  |  | ||||||
|   $('.tabular.menu .item').tab(); |   $('.tabular.menu .item').tab(); | ||||||
|  |  | ||||||
|   // prevent multiple form submissions on forms containing .loading-button |  | ||||||
|   document.addEventListener('submit', (e) => { |  | ||||||
|     const btn = e.target.querySelector('.loading-button'); |  | ||||||
|     if (!btn) return; |  | ||||||
|     if (btn.classList.contains('loading')) return e.preventDefault(); |  | ||||||
|     btn.classList.add('loading'); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   document.addEventListener('submit', formFetchAction); |   document.addEventListener('submit', formFetchAction); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -636,11 +636,6 @@ export function initSingleCommentEditor($commentForm) { | |||||||
|   const opts = {}; |   const opts = {}; | ||||||
|   const $statusButton = $('#status-button'); |   const $statusButton = $('#status-button'); | ||||||
|   if ($statusButton.length) { |   if ($statusButton.length) { | ||||||
|     $statusButton.on('click', (e) => { |  | ||||||
|       e.preventDefault(); |  | ||||||
|       $('#status').val($statusButton.data('status-val')); |  | ||||||
|       $('#comment-form').trigger('submit'); |  | ||||||
|     }); |  | ||||||
|     opts.onContentChanged = (editor) => { |     opts.onContentChanged = (editor) => { | ||||||
|       $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status')); |       $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status')); | ||||||
|     }; |     }; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user