loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>


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

</details>

authored by

wxiaoguang and committed by
GitHub
b71cb7ac a305c37e

+163 -54
+4
modules/context/base.go
··· 136 136 b.JSON(http.StatusOK, map[string]any{"redirect": redirect}) 137 137 } 138 138 139 + func (b *Base) JSONError(msg string) { 140 + b.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg}) 141 + } 142 + 139 143 // RemoteAddr returns the client machine ip address 140 144 func (b *Base) RemoteAddr() string { 141 145 return b.Req.RemoteAddr
+2 -8
modules/context/context_response.go
··· 16 16 17 17 user_model "code.gitea.io/gitea/models/user" 18 18 "code.gitea.io/gitea/modules/base" 19 + "code.gitea.io/gitea/modules/httplib" 19 20 "code.gitea.io/gitea/modules/log" 20 21 "code.gitea.io/gitea/modules/setting" 21 22 "code.gitea.io/gitea/modules/templates" ··· 49 50 continue 50 51 } 51 52 52 - // Unfortunately browsers consider a redirect Location with preceding "//", "\\" and "/\" as meaning redirect to "http(s)://REST_OF_PATH" 53 - // Therefore we should ignore these redirect locations to prevent open redirects 54 - if len(loc) > 1 && (loc[0] == '/' || loc[0] == '\\') && (loc[1] == '/' || loc[1] == '\\') { 55 - continue 56 - } 57 - 58 - u, err := url.Parse(loc) 59 - if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(loc), strings.ToLower(setting.AppURL))) { 53 + if httplib.IsRiskyRedirectURL(loc) { 60 54 continue 61 55 } 62 56
+27
modules/httplib/url.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package httplib 5 + 6 + import ( 7 + "net/url" 8 + "strings" 9 + 10 + "code.gitea.io/gitea/modules/setting" 11 + ) 12 + 13 + // IsRiskyRedirectURL returns true if the URL is considered risky for redirects 14 + func IsRiskyRedirectURL(s string) bool { 15 + // Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH" 16 + // Therefore we should ignore these redirect locations to prevent open redirects 17 + if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') { 18 + return true 19 + } 20 + 21 + u, err := url.Parse(s) 22 + if err != nil || ((u.Scheme != "" || u.Host != "") && !strings.HasPrefix(strings.ToLower(s), strings.ToLower(setting.AppURL))) { 23 + return true 24 + } 25 + 26 + return false 27 + }
+38
modules/httplib/url_test.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package httplib 5 + 6 + import ( 7 + "testing" 8 + 9 + "code.gitea.io/gitea/modules/setting" 10 + 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + func TestIsRiskyRedirectURL(t *testing.T) { 15 + setting.AppURL = "http://localhost:3000/" 16 + tests := []struct { 17 + input string 18 + want bool 19 + }{ 20 + {"", false}, 21 + {"foo", false}, 22 + {"/", false}, 23 + {"/foo?k=%20#abc", false}, 24 + 25 + {"//", true}, 26 + {"\\\\", true}, 27 + {"/\\", true}, 28 + {"\\/", true}, 29 + {"mail:a@b.com", true}, 30 + {"https://test.com", true}, 31 + {setting.AppURL + "/foo", false}, 32 + } 33 + for _, tt := range tests { 34 + t.Run(tt.input, func(t *testing.T) { 35 + assert.Equal(t, tt.want, IsRiskyRedirectURL(tt.input)) 36 + }) 37 + } 38 + }
+18 -1
modules/test/utils.go
··· 5 5 6 6 import ( 7 7 "net/http" 8 + "net/http/httptest" 8 9 "strings" 10 + 11 + "code.gitea.io/gitea/modules/json" 9 12 ) 10 13 11 14 // RedirectURL returns the redirect URL of a http response. 15 + // It also works for JSONRedirect: `{"redirect": "..."}` 12 16 func RedirectURL(resp http.ResponseWriter) string { 13 - return resp.Header().Get("Location") 17 + loc := resp.Header().Get("Location") 18 + if loc != "" { 19 + return loc 20 + } 21 + if r, ok := resp.(*httptest.ResponseRecorder); ok { 22 + m := map[string]any{} 23 + err := json.Unmarshal(r.Body.Bytes(), &m) 24 + if err == nil { 25 + if loc, ok := m["redirect"].(string); ok { 26 + return loc 27 + } 28 + } 29 + } 30 + return "" 14 31 } 15 32 16 33 func IsNormalPageCompleted(s string) bool {
+26
routers/common/redirect.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package common 5 + 6 + import ( 7 + "net/http" 8 + 9 + "code.gitea.io/gitea/modules/httplib" 10 + ) 11 + 12 + // FetchRedirectDelegate helps the "fetch" requests to redirect to the correct location 13 + func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) { 14 + // When use "fetch" to post requests and the response is a redirect, browser's "location.href = uri" has limitations. 15 + // 1. change "location" from old "/foo" to new "/foo#hash", the browser will not reload the page. 16 + // 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target. 17 + // The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2", 18 + // then frontend needs this delegate to redirect to the new location with hash correctly. 19 + redirect := req.PostFormValue("redirect") 20 + if httplib.IsRiskyRedirectURL(redirect) { 21 + resp.WriteHeader(http.StatusBadRequest) 22 + return 23 + } 24 + resp.Header().Add("Location", redirect) 25 + resp.WriteHeader(http.StatusSeeOther) 26 + }
+2
routers/init.go
··· 183 183 r.Mount("/api/v1", apiv1.Routes(ctx)) 184 184 r.Mount("/api/internal", private.Routes()) 185 185 186 + r.Post("/-/fetch-redirect", common.FetchRedirectDelegate) 187 + 186 188 if setting.Packages.Enabled { 187 189 // This implements package support for most package managers 188 190 r.Mount("/api/packages", packages_router.CommonRoutes(ctx))
+12 -19
routers/web/repo/issue.go
··· 1134 1134 } 1135 1135 1136 1136 if ctx.HasError() { 1137 - ctx.HTML(http.StatusOK, tplIssueNew) 1137 + ctx.JSONError(ctx.GetErrMsg()) 1138 1138 return 1139 1139 } 1140 1140 1141 1141 if util.IsEmptyString(form.Title) { 1142 - ctx.RenderWithErr(ctx.Tr("repo.issues.new.title_empty"), tplIssueNew, form) 1142 + ctx.JSONError(ctx.Tr("repo.issues.new.title_empty")) 1143 1143 return 1144 1144 } 1145 1145 ··· 1184 1184 1185 1185 log.Trace("Issue created: %d/%d", repo.ID, issue.ID) 1186 1186 if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { 1187 - ctx.Redirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10)) 1187 + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10)) 1188 1188 } else { 1189 - ctx.Redirect(issue.Link()) 1189 + ctx.JSONRedirect(issue.Link()) 1190 1190 } 1191 1191 } 1192 1192 ··· 2777 2777 } 2778 2778 2779 2779 if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { 2780 - ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) 2781 - ctx.Redirect(issue.Link()) 2780 + ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked")) 2782 2781 return 2783 2782 } 2784 2783 ··· 2788 2787 } 2789 2788 2790 2789 if ctx.HasError() { 2791 - ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) 2792 - ctx.Redirect(issue.Link()) 2790 + ctx.JSONError(ctx.GetErrMsg()) 2793 2791 return 2794 2792 } 2795 2793 ··· 2809 2807 pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow) 2810 2808 if err != nil { 2811 2809 if !issues_model.IsErrPullRequestNotExist(err) { 2812 - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) 2813 - ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index)) 2810 + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) 2814 2811 return 2815 2812 } 2816 2813 } ··· 2841 2838 } 2842 2839 if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.BaseBranch); !ok { 2843 2840 // todo localize 2844 - ctx.Flash.Error("The origin branch is delete, cannot reopen.") 2845 - ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index)) 2841 + ctx.JSONError("The origin branch is delete, cannot reopen.") 2846 2842 return 2847 2843 } 2848 2844 headBranchRef := pull.GetGitHeadBranchRefName() ··· 2882 2878 2883 2879 if issues_model.IsErrDependenciesLeft(err) { 2884 2880 if issue.IsPull { 2885 - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) 2886 - ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) 2881 + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) 2887 2882 } else { 2888 - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked")) 2889 - ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index)) 2883 + ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked")) 2890 2884 } 2891 2885 return 2892 2886 } ··· 2899 2893 log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) 2900 2894 } 2901 2895 } 2902 - 2903 2896 } 2904 2897 2905 2898 // Redirect to comment hashtag if there is any actual content. ··· 2908 2901 typeName = "pulls" 2909 2902 } 2910 2903 if comment != nil { 2911 - ctx.Redirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag())) 2904 + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag())) 2912 2905 } else { 2913 - ctx.Redirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) 2906 + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) 2914 2907 } 2915 2908 }() 2916 2909
+2 -2
templates/repo/issue/new_form.tmpl
··· 1 - <form class="issue-content ui comment form" id="new-issue" action="{{.Link}}" method="post"> 1 + <form class="issue-content ui comment form form-fetch-action" id="new-issue" action="{{.Link}}" method="post"> 2 2 {{.CsrfTokenHtml}} 3 3 {{if .Flash}} 4 4 <div class="sixteen wide column"> ··· 35 35 {{template "repo/issue/comment_tab" .}} 36 36 {{end}} 37 37 <div class="text right"> 38 - <button class="ui green button loading-button" tabindex="6"> 38 + <button class="ui green button" tabindex="6"> 39 39 {{if .PageIsComparePull}} 40 40 {{.locale.Tr "repo.pulls.create"}} 41 41 {{else}}
+4 -5
templates/repo/issue/view_content.tmpl
··· 96 96 {{avatar $.Context .SignedUser 40}} 97 97 </a> 98 98 <div class="content"> 99 - <form class="ui segment form" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post"> 99 + <form class="ui segment form form-fetch-action" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post"> 100 100 {{template "repo/issue/comment_tab" .}} 101 101 {{.CsrfTokenHtml}} 102 - <input id="status" name="status" type="hidden"> 103 102 <div class="field footer"> 104 103 <div class="text right"> 105 104 {{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .DisableStatusChange)}} 106 105 {{if .Issue.IsClosed}} 107 - <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"> 106 + <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"> 108 107 {{.locale.Tr "repo.issues.reopen_issue"}} 109 108 </button> 110 109 {{else}} ··· 112 111 {{if .Issue.IsPull}} 113 112 {{$closeTranslationKey = "repo.pulls.close"}} 114 113 {{end}} 115 - <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"> 114 + <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"> 116 115 {{.locale.Tr $closeTranslationKey}} 117 116 </button> 118 117 {{end}} 119 118 {{end}} 120 - <button class="ui green button loading-button" tabindex="5"> 119 + <button class="ui green button" tabindex="5"> 121 120 {{.locale.Tr "repo.issues.create_comment"}} 122 121 </button> 123 122 </div>
+1 -1
tests/integration/attachment_test.go
··· 83 83 } 84 84 85 85 req = NewRequestWithValues(t, "POST", link, postData) 86 - resp = session.MakeRequest(t, req, http.StatusSeeOther) 86 + resp = session.MakeRequest(t, req, http.StatusOK) 87 87 test.RedirectURL(resp) // check that redirect URL exists 88 88 89 89 // Validate that attachment is available
+2 -2
tests/integration/issue_test.go
··· 135 135 "title": title, 136 136 "content": content, 137 137 }) 138 - resp = session.MakeRequest(t, req, http.StatusSeeOther) 138 + resp = session.MakeRequest(t, req, http.StatusOK) 139 139 140 140 issueURL := test.RedirectURL(resp) 141 141 req = NewRequest(t, "GET", issueURL) ··· 165 165 "content": content, 166 166 "status": status, 167 167 }) 168 - resp = session.MakeRequest(t, req, http.StatusSeeOther) 168 + resp = session.MakeRequest(t, req, http.StatusOK) 169 169 170 170 req = NewRequest(t, "GET", test.RedirectURL(resp)) 171 171 resp = session.MakeRequest(t, req, http.StatusOK)
+25 -11
web_src/js/features/common-global.js
··· 9 9 import {htmlEscape} from 'escape-goat'; 10 10 import {createTippy} from '../modules/tippy.js'; 11 11 12 - const {appUrl, csrfToken, i18n} = window.config; 12 + const {appUrl, appSubUrl, csrfToken, i18n} = window.config; 13 13 14 14 export function initGlobalFormDirtyLeaveConfirm() { 15 15 // Warn users that try to leave a page after entering data into a form. ··· 61 61 }); 62 62 } 63 63 64 + // doRedirect does real redirection to bypass the browser's limitations of "location" 65 + // more details are in the backend's fetch-redirect handler 66 + function doRedirect(redirect) { 67 + const form = document.createElement('form'); 68 + const input = document.createElement('input'); 69 + form.method = 'post'; 70 + form.action = `${appSubUrl}/-/fetch-redirect`; 71 + input.type = 'hidden'; 72 + input.name = 'redirect'; 73 + input.value = redirect; 74 + form.append(input); 75 + document.body.append(form); 76 + form.submit(); 77 + } 78 + 64 79 async function formFetchAction(e) { 65 80 if (!e.target.classList.contains('form-fetch-action')) return; 66 81 ··· 101 116 const onError = (msg) => { 102 117 formEl.classList.remove('is-loading', 'small-loading-icon'); 103 118 if (errorTippy) errorTippy.destroy(); 119 + // TODO: use a better toast UI instead of the tippy. If the form height is large, the tippy position is not good 104 120 errorTippy = createTippy(formEl, { 105 121 content: msg, 106 122 interactive: true, ··· 120 136 const {redirect} = await resp.json(); 121 137 formEl.classList.remove('dirty'); // remove the areYouSure check before reloading 122 138 if (redirect) { 123 - window.location.href = redirect; 139 + doRedirect(redirect); 124 140 } else { 125 141 window.location.reload(); 126 142 } 143 + } else if (resp.status >= 400 && resp.status < 500) { 144 + const data = await resp.json(); 145 + // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" 146 + // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. 147 + onError(data.errorMessage || `server error: ${resp.status}`); 127 148 } else { 128 149 onError(`server error: ${resp.status}`); 129 150 } 130 151 } catch (e) { 131 - onError(e.error); 152 + console.error('error when doRequest', e); 153 + onError(i18n.network_error); 132 154 } 133 155 }; 134 156 ··· 182 204 $('.ui.checkbox').checkbox(); 183 205 184 206 $('.tabular.menu .item').tab(); 185 - 186 - // prevent multiple form submissions on forms containing .loading-button 187 - document.addEventListener('submit', (e) => { 188 - const btn = e.target.querySelector('.loading-button'); 189 - if (!btn) return; 190 - if (btn.classList.contains('loading')) return e.preventDefault(); 191 - btn.classList.add('loading'); 192 - }); 193 207 194 208 document.addEventListener('submit', formFetchAction); 195 209 }
-5
web_src/js/features/repo-issue.js
··· 636 636 const opts = {}; 637 637 const $statusButton = $('#status-button'); 638 638 if ($statusButton.length) { 639 - $statusButton.on('click', (e) => { 640 - e.preventDefault(); 641 - $('#status').val($statusButton.data('status-val')); 642 - $('#comment-form').trigger('submit'); 643 - }); 644 639 opts.onContentChanged = (editor) => { 645 640 $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status')); 646 641 };