From fa8f7f15ef78207d6b53c3cff26a5f22dc155245 Mon Sep 17 00:00:00 2001 From: Xing Hong <39619359+xingxing21@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:25:58 +0900 Subject: [PATCH 001/150] Always show owner/repo name in compare page dropdowns (#37172) Fixes: https://github.com/go-gitea/gitea/issues/36677 --------- Co-authored-by: wxiaoguang --- templates/repo/diff/compare.tmpl | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/templates/repo/diff/compare.tmpl b/templates/repo/diff/compare.tmpl index 87c783f4460..afd44f26a47 100644 --- a/templates/repo/diff/compare.tmpl +++ b/templates/repo/diff/compare.tmpl @@ -13,22 +13,16 @@ {{ctx.Locale.Tr "action.compare_commits_general"}} {{end}} - {{$BaseCompareName := $.BaseName -}} - {{- $HeadCompareName := $.HeadRepo.OwnerName -}} - {{- if and (eq $.BaseName $.HeadRepo.OwnerName) (ne $.Repository.Name $.HeadRepo.Name) -}} - {{- $HeadCompareName = printf "%s/%s" $.HeadRepo.OwnerName $.HeadRepo.Name -}} - {{- end -}} - {{- $OwnForkCompareName := "" -}} - {{- if .OwnForkRepo -}} - {{- $OwnForkCompareName = .OwnForkRepo.OwnerName -}} - {{- end -}} - {{- $RootRepoCompareName := "" -}} - {{- if .RootRepo -}} - {{- $RootRepoCompareName = .RootRepo.OwnerName -}} - {{- if eq $.HeadRepo.OwnerName .RootRepo.OwnerName -}} - {{- $HeadCompareName = printf "%s/%s" $.HeadRepo.OwnerName $.HeadRepo.Name -}} - {{- end -}} - {{- end -}} + {{$BaseCompareName := $.Repository.FullName -}} + {{$HeadCompareName := $.HeadRepo.FullName -}} + {{$OwnForkCompareName := "" -}} + {{if $.OwnForkRepo -}} + {{$OwnForkCompareName = $.OwnForkRepo.FullName -}} + {{end -}} + {{$RootRepoCompareName := "" -}} + {{if $.RootRepo -}} + {{$RootRepoCompareName = $.RootRepo.FullName -}} + {{end -}}
{{svg "octicon-git-compare"}} From 6eae04241d010b1490e4e80a1ff5680b2a9ab4d7 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 13 Apr 2026 20:10:43 +0200 Subject: [PATCH 002/150] Fix encoding for Matrix Webhooks (#37190) `url.PathEscape` unnecessarily encodes ! to %21, causing Matrix homeservers to reject the request with 401. Replace %21 back to ! after escaping. Fixes #36012 --------- Signed-off-by: wxiaoguang Co-authored-by: Claude Sonnet 4.6 Co-authored-by: wxiaoguang --- routers/web/repo/setting/webhook.go | 11 ++++++++++- routers/web/repo/setting/webhook_test.go | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 routers/web/repo/setting/webhook_test.go diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index b0f3a5cfee8..8c57a68b250 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -450,12 +450,21 @@ func MatrixHooksEditPost(ctx *context.Context) { editWebhook(ctx, matrixHookParams(ctx)) } +func matrixRoomIDEncode(roomID string) string { + // See https://spec.matrix.org/latest/appendices/#room-ids + // Some (unrelated) demo links: https://spec.matrix.org/latest/appendices/#matrixto-navigation + // API spec: https://spec.matrix.org/v1.18/client-server-api/#sending-events-to-a-room + // Some of their examples show links like: "PUT /rooms/!roomid:domain/state/m.example.event" + return strings.NewReplacer("%21", "!", "%3A", ":").Replace(url.PathEscape(roomID)) +} + func matrixHookParams(ctx *context.Context) webhookParams { form := web.GetForm(ctx).(*forms.NewMatrixHookForm) + // TODO: need to migrate to the latest (v3) API: https://spec.matrix.org/v1.18/client-server-api/ return webhookParams{ Type: webhook_module.MATRIX, - URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)), + URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, matrixRoomIDEncode(form.RoomID)), ContentType: webhook.ContentTypeJSON, HTTPMethod: http.MethodPut, WebhookForm: form.WebhookForm, diff --git a/routers/web/repo/setting/webhook_test.go b/routers/web/repo/setting/webhook_test.go new file mode 100644 index 00000000000..ca4a21e0755 --- /dev/null +++ b/routers/web/repo/setting/webhook_test.go @@ -0,0 +1,15 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWebhookMatrix(t *testing.T) { + assert.Equal(t, "!roomid:domain", matrixRoomIDEncode("!roomid:domain")) + assert.Equal(t, "!room%23id:domain", matrixRoomIDEncode("!room#id:domain")) // maybe it should never really happen in real world +} From 6bcb666a9d7ed14f3a77d3bea91e4c63f52e9cdb Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 14 Apr 2026 02:53:55 +0800 Subject: [PATCH 003/150] Refactor htmx and fetch-action related code (#37186) This is the first step (the hardest part): * repo file list last commit message lazy load * admin server status monitor * watch/unwatch (normal page, watchers page) * star/unstar (normal page, watchers page) * project view, delete column * workflow dispatch, switch the branch * commit page: load branches and tags referencing this commit The legacy "data-redirect" attribute is removed, it only makes the page reload (sometimes using an incorrect link). Also did cleanup for some devtest pages. --- eslint.config.ts | 1 - modules/web/middleware/cookie.go | 5 + routers/common/redirect.go | 4 +- routers/web/devtest/devtest.go | 7 +- routers/web/repo/star.go | 1 - routers/web/repo/view.go | 6 +- routers/web/repo/watch.go | 1 - routers/web/web.go | 2 +- services/context/base.go | 10 +- services/context/base_test.go | 7 +- templates/admin/dashboard.tmpl | 5 +- templates/admin/notice.tmpl | 2 +- templates/admin/system_status.tmpl | 2 +- templates/base/head.tmpl | 2 +- templates/devtest/fetch-action.tmpl | 5 +- templates/devtest/fomantic-modal.tmpl | 22 +- templates/devtest/gitea-ui.tmpl | 11 - templates/projects/view.tmpl | 2 +- templates/repo/actions/workflow_dispatch.tmpl | 13 +- .../actions/workflow_dispatch_inputs.tmpl | 2 + .../repo/commit_load_branches_and_tags.tmpl | 2 +- .../view_content/update_branch_by_merge.tmpl | 2 +- templates/repo/settings/webhook/history.tmpl | 2 +- templates/repo/star_unstar.tmpl | 32 +- templates/repo/user_cards.tmpl | 13 +- templates/repo/view_list.tmpl | 12 +- templates/repo/watch_unwatch.tmpl | 32 +- templates/status/500.tmpl | 1 - types.d.ts | 7 + web_src/css/repo/home-file-list.css | 5 + web_src/js/features/admin/common.ts | 2 +- web_src/js/features/common-fetch-action.ts | 382 ++++++++++++++---- web_src/js/features/comp/WebHookEditor.ts | 7 +- web_src/js/features/repo-diff-commit.ts | 2 +- web_src/js/features/repo-issue-pull.ts | 3 - web_src/js/globals.ts | 1 - web_src/js/index.ts | 2 - web_src/js/modules/devtest.ts | 49 ++- web_src/js/modules/fetch.ts | 14 +- web_src/js/utils.test.ts | 14 +- web_src/js/utils.ts | 5 - 41 files changed, 457 insertions(+), 242 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index 5b7884bdce3..36c3b8d1e5b 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -575,7 +575,6 @@ export default defineConfig([ 'no-restricted-imports': [2, {paths: [ {name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true}, {name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true}, - {name: 'idiomorph/htmx', message: 'Loaded in globals.ts', allowTypeImports: true}, ]}], 'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'], 'no-return-assign': [0], diff --git a/modules/web/middleware/cookie.go b/modules/web/middleware/cookie.go index 336c276fe8f..5456e3c6b70 100644 --- a/modules/web/middleware/cookie.go +++ b/modules/web/middleware/cookie.go @@ -35,6 +35,11 @@ func DeleteRedirectToCookie(resp http.ResponseWriter) { } func RedirectLinkUserLogin(req *http.Request) string { + if req.Header.Get("X-Gitea-Fetch-Action") != "" { + // when building the redirect link for a fetch request, the current link might be a partial page, + // so we only redirect to the login page without redirect_to parameter + return setting.AppSubURL + "/user/login" + } return setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(setting.AppSubURL+req.URL.RequestURI()) } diff --git a/routers/common/redirect.go b/routers/common/redirect.go index d64f74ec82a..2e1f315f2d8 100644 --- a/routers/common/redirect.go +++ b/routers/common/redirect.go @@ -16,8 +16,8 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) { // 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.IsCurrentGiteaSiteURL(req.Context(), redirect) { + redirect := req.FormValue("redirect") + if req.Method != http.MethodPost || !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) { resp.WriteHeader(http.StatusBadRequest) return } diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go index 8bc5947df8f..af548430169 100644 --- a/routers/web/devtest/devtest.go +++ b/routers/web/devtest/devtest.go @@ -45,7 +45,7 @@ func List(ctx *context.Context) { func FetchActionTest(ctx *context.Context) { _ = ctx.Req.ParseForm() - ctx.Flash.Info("fetch-action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" + + ctx.Flash.Info("fetch action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" + "Form: " + ctx.Req.Form.Encode() + "\n" + "PostForm: " + ctx.Req.PostForm.Encode(), ) @@ -241,9 +241,8 @@ func prepareMockDataUnicodeEscape(ctx *context.Context) { func TmplCommon(ctx *context.Context) { prepareMockData(ctx) - if ctx.Req.Method == http.MethodPost { - _ = ctx.Req.ParseForm() - ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+ + if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") { + ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+ "Form: "+ctx.Req.Form.Encode()+"\n"+ "PostForm: "+ctx.Req.PostForm.Encode(), true, diff --git a/routers/web/repo/star.go b/routers/web/repo/star.go index 00c06b7d02d..8cfbfefdf14 100644 --- a/routers/web/repo/star.go +++ b/routers/web/repo/star.go @@ -26,6 +26,5 @@ func ActionStar(ctx *context.Context) { ctx.ServerError("GetRepositoryByName", err) return } - ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl ctx.HTML(http.StatusOK, tplStarUnstar) } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 46661f0df0f..7f59e6b3893 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -310,13 +310,15 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri return nil } - { + { // this block is for testing purpose only if timeout != 0 && !setting.IsProd && !setting.IsInTesting { log.Debug("first call to get directory file commit info") clearFilesCommitInfo := func() { log.Warn("clear directory file commit info to force async loading on frontend") for i := range files { - files[i].Commit = nil + if i%2 == 0 { // for testing purpose, only clear half of the files' commit info + files[i].Commit = nil + } } } _ = clearFilesCommitInfo diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go index 70c548b8cea..a7fbfc168be 100644 --- a/routers/web/repo/watch.go +++ b/routers/web/repo/watch.go @@ -26,6 +26,5 @@ func ActionWatch(ctx *context.Context) { ctx.ServerError("GetRepositoryByName", err) return } - ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl ctx.HTML(http.StatusOK, tplWatchUnwatch) } diff --git a/routers/web/web.go b/routers/web/web.go index 61d1fdc1421..879a6f3afd6 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1710,7 +1710,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/forks", repo.Forks) m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff) - m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit) + m.Get("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit) }, optSignIn, context.RepoAssignment, reqUnitCodeReader) // end "/{username}/{reponame}": repo code diff --git a/services/context/base.go b/services/context/base.go index 8d44de5bc72..c5ec4b419a2 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -159,12 +159,10 @@ func (b *Base) Redirect(location string, status ...int) { // So in this case, we should remove the session cookie from the response header removeSessionCookieHeader(b.Resp) } - // in case the request is made by htmx, have it redirect the browser instead of trying to follow the redirect inside htmx - if b.Req.Header.Get("HX-Request") == "true" { - b.Resp.Header().Set("HX-Redirect", location) - // we have to return a non-redirect status code so XMLHTTPRequest will not immediately follow the redirect - // so as to give htmx redirect logic a chance to run - b.Status(http.StatusNoContent) + // In case the request is made by "fetch-action" module, make JS redirect to the new location + // Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected. + if b.Req.Header.Get("X-Gitea-Fetch-Action") != "" { + b.JSON(http.StatusOK, map[string]any{"redirect": location}) return } http.Redirect(b.Resp, b.Req, location, code) diff --git a/services/context/base_test.go b/services/context/base_test.go index 2a4f86dddf8..f9bbe717290 100644 --- a/services/context/base_test.go +++ b/services/context/base_test.go @@ -38,9 +38,10 @@ func TestRedirect(t *testing.T) { req, _ = http.NewRequest(http.MethodGet, "/", nil) resp := httptest.NewRecorder() - req.Header.Add("HX-Request", "true") + req.Header.Add("X-Gitea-Fetch-Action", "1") b := NewBaseContextForTest(resp, req) b.Redirect("/other") - assert.Equal(t, "/other", resp.Header().Get("HX-Redirect")) - assert.Equal(t, http.StatusNoContent, resp.Code) + assert.Contains(t, resp.Header().Get("Content-Type"), "application/json") + assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String()) + assert.Equal(t, http.StatusOK, resp.Code) } diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl index 834c56672f0..f44dc243622 100644 --- a/templates/admin/dashboard.tmpl +++ b/templates/admin/dashboard.tmpl @@ -76,10 +76,7 @@ {{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}}
-
-
- {{template "admin/system_status" .}} -
+ {{template "admin/system_status" .}}
{{template "admin/layout_footer" .}} diff --git a/templates/admin/notice.tmpl b/templates/admin/notice.tmpl index 7e08946f23b..55ee9da8520 100644 --- a/templates/admin/notice.tmpl +++ b/templates/admin/notice.tmpl @@ -50,7 +50,7 @@ - diff --git a/templates/admin/system_status.tmpl b/templates/admin/system_status.tmpl index 7b5c9be6ccc..8134db9eb26 100644 --- a/templates/admin/system_status.tmpl +++ b/templates/admin/system_status.tmpl @@ -1,4 +1,4 @@ -
+
{{ctx.Locale.Tr "admin.dashboard.server_uptime"}}
{{.SysStatus.StartTime}}
{{ctx.Locale.Tr "admin.dashboard.current_goroutine"}}
diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index 381e559b9db..90f6b7546e5 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -23,7 +23,7 @@ {{template "base/head_script" .}} {{template "custom/header" .}} - + {{template "custom/body_outer_pre" .}}
diff --git a/templates/devtest/fetch-action.tmpl b/templates/devtest/fetch-action.tmpl index cd4da52aac3..e8fddf17b09 100644 --- a/templates/devtest/fetch-action.tmpl +++ b/templates/devtest/fetch-action.tmpl @@ -3,7 +3,7 @@

link-action

- Use "window.fetch" to send a request to backend, the request is defined in an "A" or "BUTTON" element. + The request is defined in an "A" or "BUTTON" element. It might be renamed to "link-fetch-action" to match the "form-fetch-action".
@@ -15,7 +15,6 @@

form-fetch-action

-
Use "window.fetch" to send a form request to backend
@@ -25,7 +24,7 @@
-
+
bad action url
diff --git a/templates/devtest/fomantic-modal.tmpl b/templates/devtest/fomantic-modal.tmpl index 8e769790b25..98c3f332ae7 100644 --- a/templates/devtest/fomantic-modal.tmpl +++ b/templates/devtest/fomantic-modal.tmpl @@ -1,21 +1,9 @@ {{template "devtest/devtest-header"}}
- {{template "base/alert" .}} - - diff --git a/templates/repo/actions/workflow_dispatch_inputs.tmpl b/templates/repo/actions/workflow_dispatch_inputs.tmpl index 085fd553de5..10badeb6177 100644 --- a/templates/repo/actions/workflow_dispatch_inputs.tmpl +++ b/templates/repo/actions/workflow_dispatch_inputs.tmpl @@ -1,3 +1,4 @@ +
{{if not .WorkflowDispatchConfig}}
{{/* using "ui message" in "ui form" needs to force to display */}} {{if not .CurWorkflowExists}} @@ -44,3 +45,4 @@
{{end}} {{end}} +
diff --git a/templates/repo/commit_load_branches_and_tags.tmpl b/templates/repo/commit_load_branches_and_tags.tmpl index ecb210c575c..162d805a29e 100644 --- a/templates/repo/commit_load_branches_and_tags.tmpl +++ b/templates/repo/commit_load_branches_and_tags.tmpl @@ -8,7 +8,7 @@
{{end}}
diff --git a/templates/repo/issue/view_content/update_branch_by_merge.tmpl b/templates/repo/issue/view_content/update_branch_by_merge.tmpl index 5b306e3cbea..f6fa66eba76 100644 --- a/templates/repo/issue/view_content/update_branch_by_merge.tmpl +++ b/templates/repo/issue/view_content/update_branch_by_merge.tmpl @@ -9,7 +9,7 @@ {{if and $.UpdateAllowed $.UpdateByRebaseAllowed}}
- diff --git a/templates/repo/star_unstar.tmpl b/templates/repo/star_unstar.tmpl index dea965ab307..7e4c61aa286 100644 --- a/templates/repo/star_unstar.tmpl +++ b/templates/repo/star_unstar.tmpl @@ -1,13 +1,19 @@ -
- -
+
+ {{$buttonText := ctx.Locale.Tr "repo.star"}} + {{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}} + + + {{CountFmt .Repository.NumStars}} + +
diff --git a/templates/repo/user_cards.tmpl b/templates/repo/user_cards.tmpl index 6c43300a6aa..ee33e4b18e8 100644 --- a/templates/repo/user_cards.tmpl +++ b/templates/repo/user_cards.tmpl @@ -1,14 +1,5 @@ - -
-
+{{/* need to reload after "watch/unwatch" or "star/unstar" fetch actions */}} +
{{if .CardsTitle}}

{{.CardsTitle}} diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl index dae4ed5f5b1..34485fc65d7 100644 --- a/templates/repo/view_list.tmpl +++ b/templates/repo/view_list.tmpl @@ -1,5 +1,11 @@ {{/* use grid layout, still use the old ID because there are many other CSS styles depending on this ID */}} -
+
{{template "repo/latest_commit" .}}
{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}
@@ -15,7 +21,7 @@ {{$entry := $item.Entry}} {{$commit := $item.Commit}} {{$submoduleFile := $item.SubmoduleFile}} -

- {{$isMember := .IsOrganizationMember}} - {{range .Members}} - {{if or $isMember (call $.IsPublicMember .ID)}} - {{ctx.AvatarUtils.Avatar . 48}} + {{range $memberUser := .OrgOverviewMembers}} + {{if or $.IsOrganizationMember (call $.IsPublicMember $memberUser.ID)}} + {{template "shared/user/avatarlink" dict "user" $memberUser "size" 32 "tooltip" true}} {{end}} {{end}}
@@ -74,7 +73,7 @@ {{.Org.NumTeams}} {{svg "octicon-chevron-right"}}
- {{range .Teams}} + {{range .OrgOverviewTeams}}
{{.Name}}

diff --git a/templates/org/team/new.tmpl b/templates/org/team/new.tmpl index abf728fc544..f8785bb466a 100644 --- a/templates/org/team/new.tmpl +++ b/templates/org/team/new.tmpl @@ -20,7 +20,7 @@

- + {{ctx.Locale.Tr "org.team_desc_helper"}}
{{if not (eq .Team.LowerName "owners")}} diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index 8678ed74544..645c94d4162 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -1,17 +1,16 @@
-

+

{{.Team.Name}} -
+
{{if .Team.IsMember ctx $.SignedUser.ID}} -
- -
+ {{else if .IsOrganizationOwner}}
- +
{{end}}
@@ -85,12 +84,12 @@
{{end}}

- -
{{end}} +
+
+ + +
+
+
- {{range .Teams}} -
-
- {{.Name}} -
- {{ctx.Locale.Tr "view"}} + {{range $team := $.OrgListTeams}} +
+ -
- {{range .Members}} - {{template "shared/user/avatarlink" dict "user" .}} - {{end}} + {{if $team.Description}} +
+ {{if $team.Description}}{{$team.Description}}{{end}}
-
-

{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}} · {{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}

+ {{end}} +
+
+ {{range .Members}} + {{template "shared/user/avatarlink" dict "user" . "size" 32 "tooltip" true}} + {{else}} + {{ctx.Locale.Tr "org.teams.add_team_member"}} + {{end}} +
{{end}}
+ {{template "base/paginate" .}}
- {{if not .ReadmeInList}}
@@ -90,15 +92,15 @@
- {{if not .IsMarkup}} + {{if not .RenderAsMarkup}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} -
+
{{if .IsFileTooLarge}} {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} {{else if not .FileSize}} {{template "shared/fileisempty"}} - {{else if .IsMarkup}} + {{else if .RenderAsMarkup}} {{.FileContent}} {{else if .IsPlainText}}
{{if .FileContent}}{{.FileContent}}{{end}}
diff --git a/templates/swagger/openapi-viewer.tmpl b/templates/swagger/openapi-viewer.tmpl index f2f01fc0cd3..792364157d7 100644 --- a/templates/swagger/openapi-viewer.tmpl +++ b/templates/swagger/openapi-viewer.tmpl @@ -3,8 +3,8 @@ {{ctx.HeadMetaContentSecurityPolicy}} Gitea API - {{/* HINT: SWAGGER-OPENAPI-VIEWER: another place is "modules/markup/external/openapi.go" */}} + {{/* HINT: SWAGGER-CSS-IMPORT: import swagger styles ahead to avoid UI flicker (e.g.: the swagger-back-link element) */}} diff --git a/tests/e2e/external-render.test.ts b/tests/e2e/external-render.test.ts index 50adb6429e0..b989c354ff5 100644 --- a/tests/e2e/external-render.test.ts +++ b/tests/e2e/external-render.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertNoJsError, randomString} from './utils.ts'; +import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts'; test('external file', async ({page, request}) => { const repoName = `e2e-external-render-${randomString(8)}`; @@ -17,6 +17,7 @@ test('external file', async ({page, request}) => { await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`)); const frame = page.frameLocator('iframe.external-render-iframe'); await expect(frame.locator('p')).toContainText('rendered content'); + await assertFlushWithParent(iframe, page.locator('.file-view')); await assertNoJsError(page); } finally { await apiDeleteRepo(request, owner, repoName); @@ -31,13 +32,28 @@ test('openapi file', async ({page, request}) => { login(page), ]); try { - const spec = 'openapi: "3.0.0"\ninfo:\n title: Test API\n version: "1.0"\npaths: {}\n'; - await apiCreateFile(request, owner, repoName, 'openapi.yaml', spec); - await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.yaml`); + const title = 'Test & "quoted"'; + const spec = JSON.stringify({ + openapi: '3.0.0', + info: {title, version: '1.0'}, + paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}}, + components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}}, + }); + await apiCreateFile(request, owner, repoName, 'openapi.json', spec); + await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`); const iframe = page.locator('iframe.external-render-iframe'); await expect(iframe).toBeVisible(); - const frame = page.frameLocator('iframe.external-render-iframe'); - await expect(frame.locator('#swagger-ui .swagger-ui')).toBeVisible(); + const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer'); + await expect(viewer.locator('.swagger-ui')).toBeVisible(); + await expect(viewer.locator('.info .title')).toContainText(title); + // expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location + // (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference" + await viewer.locator('.opblock-tag').first().click(); + await viewer.locator('.opblock').first().click(); + await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0); + // poll: postMessage resize may not have settled yet when the visibility checks pass + await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); await assertNoJsError(page); } finally { await apiDeleteRepo(request, owner, repoName); diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts new file mode 100644 index 00000000000..a3afe85b267 --- /dev/null +++ b/tests/e2e/file-view-render.test.ts @@ -0,0 +1,69 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import {apiCreateBranch, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; + +test('3d model file', async ({page, request}) => { + const repoName = `e2e-3d-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await apiCreateRepo(request, {name: repoName}); + try { + const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n'; + await apiCreateFile(request, owner, repoName, 'test.stl', stl); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + const frame = page.frameLocator('iframe.external-render-iframe'); + const viewer = frame.locator('#frontend-render-viewer'); + await expect(viewer.locator('canvas')).toBeVisible(); + expect((await viewer.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); + // bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent + const [parentBg, iframeBg] = await Promise.all([ + page.evaluate(() => getComputedStyle(document.body).backgroundColor), + frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor), + ]); + expect(iframeBg).toBe(parentBg); + await assertNoJsError(page); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); + +test('pdf file', async ({page, request}) => { + // headless playwright cannot render PDFs (PDFObject.embed returns false), so this is a limited test + const repoName = `e2e-pdf-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await apiCreateRepo(request, {name: repoName}); + try { + await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n'); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`); + const container = page.locator('.file-view-render-container'); + await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer'); + expect((await container.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(container, page.locator('.file-view')); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); + +test('asciicast file', async ({page, request}) => { + // regression for repo_file.go's RefTypeNameSubURL double-escape: readme.cast on a non-ASCII branch + // is rendered via view_readme.go (no metas override), exposing the bug as a broken player URL + const repoName = `e2e-asciicast-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + const branch = '日本語-branch'; + const branchEnc = encodeURIComponent(branch); + await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]); + try { + const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; + await apiCreateFile(request, owner, repoName, 'readme.cast', cast); + await apiCreateBranch(request, owner, repoName, branch); + await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`); + const container = page.locator('.asciinema-player-container'); + await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`); + await expect(container.locator('.ap-wrapper')).toBeVisible(); + expect((await container.boundingBox())!.height).toBeGreaterThan(300); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 7a4a91c2699..08de0241268 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect} from '@playwright/test'; -import type {APIRequestContext, Page} from '@playwright/test'; +import type {APIRequestContext, Locator, Page} from '@playwright/test'; /** Generate a random alphanumeric string. */ export function randomString(length: number): string { @@ -67,6 +67,13 @@ export async function apiCreateFile(requestContext: APIRequestContext, owner: st }), 'apiCreateFile'); } +export async function apiCreateBranch(requestContext: APIRequestContext, owner: string, repo: string, newBranch: string) { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/branches`, { + headers: apiHeaders(), + data: {new_branch_name: newBranch}, + }), 'apiCreateBranch'); +} + export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, { headers: apiHeaders(), @@ -115,6 +122,15 @@ export async function assertNoJsError(page: Page) { await expect(page.locator('.js-global-error')).toHaveCount(0); } +/* asserts the child has no horizontal inset from its parent — catches padding/border anywhere + * in between regardless of which element declares it */ +export async function assertFlushWithParent(child: Locator, parent: Locator) { + const [childBox, parentBox] = await Promise.all([child.boundingBox(), parent.boundingBox()]); + if (!childBox || !parentBox) throw new Error('boundingBox returned null'); + expect(childBox.x).toBe(parentBox.x); + expect(childBox.width).toBe(parentBox.width); +} + export async function logout(page: Page) { await page.context().clearCookies(); // workaround issues related to fomantic dropdown await page.goto('/'); diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index 681b981a4e1..97ef7e0b22a 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -108,7 +108,12 @@ func TestExternalMarkupRenderer(t *testing.T) { // default sandbox in sub page response assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "
<script></script>
`, respSub.Body.String()) + assert.Equal(t, + ``+ + ``+ + `
<script></script>
`, + respSub.Body.String(), + ) }) }) @@ -129,9 +134,14 @@ func TestExternalMarkupRenderer(t *testing.T) { }) t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { - req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") + req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer?a=1%2f2") respSub := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, ``, respSub.Body.String()) + assert.Equal(t, + ``+ + ``+ + ``, + respSub.Body.String(), + ) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) }) }) diff --git a/vite.config.ts b/vite.config.ts index 7249cb902eb..731726c3183 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -152,9 +152,15 @@ function iifePlugin(sourceFileName: string): Plugin { if (!entry) throw new Error('IIFE build produced no output'); const manifestPath = join(outDir, '.vite', 'manifest.json'); - const manifestData = JSON.parse(readFileSync(manifestPath, 'utf8')); - manifestData[`web_src/js/${sourceFileName}`] = {file: entry.fileName, name: sourceBaseName, isEntry: true}; - writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2)); + try { + const manifestData = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifestData[`web_src/js/${sourceFileName}`] = {file: entry.fileName, name: sourceBaseName, isEntry: true}; + writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2)); + } catch { + // FIXME: if it throws error here, the real Vite compilation error will be hidden, and makes the debug very difficult + // Need to find a correct way to handle errors. + console.error(`Failed to update manifest for ${sourceFileName}`); + } }, }; } @@ -165,6 +171,7 @@ function reducedSourcemapPlugin(): Plugin { 'js/index.', 'js/iife.', 'js/swagger.', + 'js/external-render-frontend.', 'js/external-render-helper.', 'js/eventsource.sharedworker.', ]; @@ -251,8 +258,10 @@ export default defineConfig(commonViteOpts({ manifest: true, rolldownOptions: { input: { + // FIXME: INCORRECT-VITE-MANIFEST-PARSER: the "css importing" logic in backend is wrong index: join(import.meta.dirname, 'web_src/js/index.ts'), swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'), + 'external-render-frontend': join(import.meta.dirname, 'web_src/js/external-render-frontend.ts'), 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/eventsource.sharedworker.ts'), devtest: join(import.meta.dirname, 'web_src/css/devtest.css'), ...themes, diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index efa6947ef14..d90e3e01ec5 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -458,9 +458,11 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { } .external-render-iframe { + display: block; /* removes the inline baseline gap below the iframe */ width: 100%; height: max(300px, 80vh); border: none; + border-radius: 0 0 var(--border-radius) var(--border-radius); } .markup-content-iframe { diff --git a/web_src/js/external-render-frontend.ts b/web_src/js/external-render-frontend.ts new file mode 100644 index 00000000000..9d969bcf900 --- /dev/null +++ b/web_src/js/external-render-frontend.ts @@ -0,0 +1,67 @@ +import type {FrontendRenderFunc, FrontendRenderOptions} from './render/plugin.ts'; + +type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>; + +// It must use a wrapper function to avoid the "import" statement being treated +// as static import and cause the all plugins being loaded together, +// We only need to load the plugins we need. +const frontendPlugins: Record = { + 'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'), + 'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'), +}; + +class Options implements FrontendRenderOptions { + container: HTMLElement; + treePath: string; + rawEncoding: string; + rawString: string; + cachedBytes: Uint8Array | null = null; + cachedString: string | null = null; + constructor(container: HTMLElement, treePath: string, rawEncoding: string, rawString: string) { + this.container = container; + this.treePath = treePath; + this.rawEncoding = rawEncoding; + this.rawString = rawString; + } + decodeBase64(): Uint8Array { + return Uint8Array.from(atob(this.rawString), (c) => c.charCodeAt(0)); + } + contentBytes(): Uint8Array { + if (this.cachedBytes === null) { + this.cachedBytes = this.rawEncoding === 'base64' ? this.decodeBase64() : new TextEncoder().encode(this.rawString); + } + return this.cachedBytes; + } + contentString(): string { + if (this.cachedString === null) { + this.cachedString = this.rawEncoding === 'base64' ? new TextDecoder('utf-8').decode(this.decodeBase64()) : this.rawString; + } + return this.cachedString; + } +} + +async function initFrontendExternalRender() { + const viewerContainer = document.querySelector('#frontend-render-viewer')!; + const renderNames = viewerContainer.getAttribute('data-frontend-renders')!.split(' '); + const fileTreePath = viewerContainer.getAttribute('data-file-tree-path')!; + + const fileDataElem = document.querySelector('#frontend-render-data')!; + fileDataElem.remove(); + const fileDataContent = fileDataElem.value; + const fileDataEncoding = fileDataElem.getAttribute('data-content-encoding')!; + const opts = new Options(viewerContainer, fileTreePath, fileDataEncoding, fileDataContent); + + let found = false; + for (const name of renderNames) { + if (!(name in frontendPlugins)) continue; + const plugin = await frontendPlugins[name](); + found = true; + if (await plugin.frontendRender(opts)) break; + } + + if (!found) { + viewerContainer.textContent = 'No frontend render plugin found for this file, but backend declares that there must be one, there must be a bug'; + } +} + +initFrontendExternalRender(); diff --git a/web_src/js/external-render-helper.ts b/web_src/js/external-render-helper.ts index 9162d0f550d..f92aeb9c6c9 100644 --- a/web_src/js/external-render-helper.ts +++ b/web_src/js/external-render-helper.ts @@ -26,14 +26,16 @@ function isValidCssColor(s: string | null): boolean { return reHex.test(s) || reRgb.test(s); } -const url = new URL(window.location.href); +const thisScriptElem = document.querySelector('script#gitea-external-render-helper'); +const queryString = thisScriptElem?.getAttribute('data-render-query-string') ?? window.location.search.substring(1); +const queryParams = new URLSearchParams(queryString); -const isDarkTheme = url.searchParams.get('gitea-is-dark-theme') === 'true'; +const isDarkTheme = queryParams.get('gitea-is-dark-theme') === 'true'; if (isDarkTheme) { document.documentElement.setAttribute('data-gitea-theme-dark', String(isDarkTheme)); } -const backgroundColor = url.searchParams.get('gitea-iframe-bgcolor'); +const backgroundColor = queryParams.get('gitea-iframe-bgcolor'); if (isValidCssColor(backgroundColor)) { // create a style element to set background color, then it can be overridden by the content page's own style if needed const style = document.createElement('style'); @@ -41,12 +43,13 @@ if (isValidCssColor(backgroundColor)) { :root { --gitea-iframe-bgcolor: ${backgroundColor}; } +html, body { margin: 0; padding: 0 } body { background: ${backgroundColor}; } `; document.head.append(style); } -const iframeId = url.searchParams.get('gitea-iframe-id'); +const iframeId = queryParams.get('gitea-iframe-id'); if (iframeId) { // iframe is in different origin, so we need to use postMessage to communicate const postIframeMsg = (cmd: string, data: Record = {}) => { diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index ff9e8cfa263..b9a5dd5094f 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -1,29 +1,19 @@ -import type {FileRenderPlugin} from '../render/plugin.ts'; -import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; -import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; +import type {InplaceRenderPlugin} from '../render/plugin.ts'; +import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; -import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; import {html} from '../utils/html.ts'; import {basename} from '../utils.ts'; -const plugins: FileRenderPlugin[] = []; +const inplacePlugins: InplaceRenderPlugin[] = []; -function initPluginsOnce(): void { - if (plugins.length) return; - plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer()); +function initInplacePluginsOnce(): void { + if (inplacePlugins.length) return; + inplacePlugins.push(newInplacePluginPdfViewer()); } -function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { - return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; -} - -function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void { - const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons')!; - showElem(toggleButtons); - const displayingRendered = Boolean(renderContainer); - toggleElemClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist - toggleElemClass(toggleButtons.querySelector('.file-view-toggle-rendered')!, 'active', displayingRendered); - // TODO: if there is only one button, hide it? +function findInplaceRenderPlugin(filename: string, mimeType: string): InplaceRenderPlugin | null { + return inplacePlugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; } async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) { @@ -32,7 +22,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str let rendered = false, errorMsg = ''; try { - const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType); if (plugin) { container.classList.add('is-loading'); container.setAttribute('data-render-name', plugin.name); // not used yet @@ -61,16 +51,13 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str export function initRepoFileView(): void { registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { - initPluginsOnce(); + initInplacePluginsOnce(); const rawFileLink = elFileView.getAttribute('data-raw-file-link')!; const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet - // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not - const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType); if (!plugin) return; const renderContainer = elFileView.querySelector('.file-view-render-container'); - showRenderRawFileButton(elFileView, renderContainer); - // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType); }); } diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts index 63510458f9b..77ba0eaed4f 100644 --- a/web_src/js/markup/content.ts +++ b/web_src/js/markup/content.ts @@ -3,13 +3,14 @@ import {initMarkupCodeMath} from './math.ts'; import {initMarkupCodeCopy} from './codecopy.ts'; import {initMarkupRenderAsciicast} from './asciicast.ts'; import {initMarkupTasklist} from './tasklist.ts'; -import {registerGlobalSelectorFunc} from '../modules/observer.ts'; -import {initMarkupRenderIframe} from './render-iframe.ts'; +import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; +import {initExternalRenderIframe} from './render-iframe.ts'; import {initMarkupRefIssue} from './refissue.ts'; import {toggleElemClass} from '../utils/dom.ts'; // code that runs for all markup content export function initMarkupContent(): void { + registerGlobalInitFunc('initExternalRenderIframe', initExternalRenderIframe); registerGlobalSelectorFunc('.markup', (el: HTMLElement) => { if (el.matches('.truncated-markup')) { // when the rendered markup is truncated (e.g.: user's home activity feed) @@ -25,7 +26,6 @@ export function initMarkupContent(): void { initMarkupCodeMermaid(el); initMarkupCodeMath(el); initMarkupRenderAsciicast(el); - initMarkupRenderIframe(el); initMarkupRefIssue(el); }); } diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 09493df7802..2b1b06e5c0b 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -1,5 +1,6 @@ -import {generateElemId, queryElemChildren} from '../utils/dom.ts'; +import {generateElemId} from '../utils/dom.ts'; import {isDarkTheme} from '../utils.ts'; +import {GET} from '../modules/fetch.ts'; function safeRenderIframeLink(link: any): string | null { try { @@ -41,7 +42,7 @@ function getRealBackgroundColor(el: HTMLElement) { return ''; } -async function loadRenderIframeContent(iframe: HTMLIFrameElement) { +export async function initExternalRenderIframe(iframe: HTMLIFrameElement) { const iframeSrcUrl = iframe.getAttribute('data-src')!; if (!iframe.id) iframe.id = generateElemId('gitea-iframe-'); @@ -62,9 +63,10 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme())); u.searchParams.set('gitea-iframe-id', iframe.id); u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe)); - iframe.src = u.href; -} -export function initMarkupRenderIframe(el: HTMLElement) { - queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent); + // It must use "srcdoc" here, because our backend always sends CSP sandbox directive for the rendered content + // (to protect from XSS risks), so we can't use "src" to load the content directly, otherwise there will be console errors like: + // Unsafe attempt to load URL http://localhost:3000/test from frame with URL http://localhost:3000/test + const resp = await GET(u.href); + iframe.srcdoc = await resp.text(); } diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts index 234be4118f4..368c73dea36 100644 --- a/web_src/js/render/plugin.ts +++ b/web_src/js/render/plugin.ts @@ -1,10 +1,21 @@ -export type FileRenderPlugin = { - // unique plugin name +// there are 2 kinds of plugins: +// * "inplace" plugins: render file content in-place, e.g. PDF viewer +// * "frontend" plugins: render file content in a separate iframe by a huge frontend library (need to protect from XSS risks) +// TODO: render plugin enhancements, not needed at the moment, leave the problems to the future when the problems actually come: +// 1. provide the prefetched file head bytes to let the plugin decide whether to render or not +// 2. multiple plugins can render the same file, so we should not assume only one plugin will render it + +export type InplaceRenderPlugin = { name: string; - - // test if plugin can handle a specified file canHandle: (filename: string, mimeType: string) => boolean; - - // render file content render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; }; + +export type FrontendRenderOptions = { + container: HTMLElement; + treePath: string; + contentString(): string; + contentBytes(): Uint8Array; +}; + +export type FrontendRenderFunc = (opts: FrontendRenderOptions) => Promise; diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts deleted file mode 100644 index f997790af69..00000000000 --- a/web_src/js/render/plugins/3d-viewer.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type {FileRenderPlugin} from '../plugin.ts'; -import {extname} from '../../utils.ts'; - -// support common 3D model file formats, use online-3d-viewer library for rendering - -/* a simple text STL file example: -solid SimpleTriangle - facet normal 0 0 1 - outer loop - vertex 0 0 0 - vertex 1 0 0 - vertex 0 1 0 - endloop - endfacet -endsolid SimpleTriangle -*/ - -export function newRenderPlugin3DViewer(): FileRenderPlugin { - // Some extensions are text-based formats: - // .3mf .amf .brep: XML - // .fbx: XML or BINARY - // .dae .gltf: JSON - // .ifc, .igs, .iges, .stp, .step are: TEXT - // .stl .ply: TEXT or BINARY - // .obj .off .wrl: TEXT - // So we need to be able to render when the file is recognized as plaintext file by backend. - // - // It needs more logic to make it overall right (render a text 3D model automatically): - // we need to distinguish the ambiguous filename extensions. - // For example: "*.obj, *.off, *.step" might be or not be a 3D model file. - // So when it is a text file, we can't assume that "we only render it by 3D plugin", - // otherwise the end users would be impossible to view its real content when the file is not a 3D model. - const SUPPORTED_EXTENSIONS = [ - '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', - '.dae', '.fbx', '.fcstd', '.glb', '.gltf', - '.ifc', '.igs', '.iges', '.stp', '.step', - '.stl', '.obj', '.off', '.ply', '.wrl', - ]; - - return { - name: '3d-model-viewer', - - canHandle(filename: string, _mimeType: string): boolean { - const ext = extname(filename).toLowerCase(); - return SUPPORTED_EXTENSIONS.includes(ext); - }, - - async render(container: HTMLElement, fileUrl: string): Promise { - // TODO: height and/or max-height? - const OV = await import('online-3d-viewer'); - const viewer = new OV.EmbeddedViewer(container, { - backgroundColor: new OV.RGBAColor(59, 68, 76, 0), - defaultColor: new OV.RGBColor(65, 131, 196), - edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), - }); - viewer.LoadModelFromUrlList([fileUrl]); - }, - }; -} diff --git a/web_src/js/render/plugins/frontend-openapi-swagger.ts b/web_src/js/render/plugins/frontend-openapi-swagger.ts new file mode 100644 index 00000000000..cc8d3451f24 --- /dev/null +++ b/web_src/js/render/plugins/frontend-openapi-swagger.ts @@ -0,0 +1,17 @@ +import type {FrontendRenderFunc} from '../plugin.ts'; +import {initSwaggerUI} from '../swagger.ts'; + +// HINT: SWAGGER-CSS-IMPORT: this import is also necessary when swagger is used as a frontend external render +// It must be on top-level, doesn't work in a function +// Static import doesn't work (it needs to use manifest.json to manually add the CSS file) +await import('../../../css/swagger.css'); + +export const frontendRender: FrontendRenderFunc = async (opts): Promise => { + try { + await initSwaggerUI(opts.container, {specText: opts.contentString()}); + return true; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/web_src/js/render/plugins/frontend-viewer-3d.ts b/web_src/js/render/plugins/frontend-viewer-3d.ts new file mode 100644 index 00000000000..f7d1c4d0541 --- /dev/null +++ b/web_src/js/render/plugins/frontend-viewer-3d.ts @@ -0,0 +1,36 @@ +import type {FrontendRenderFunc} from '../plugin.ts'; +import {basename} from '../../utils.ts'; +import * as OV from 'online-3d-viewer'; +import {colord} from 'colord'; + +/* a simple text STL file example: +solid SimpleTriangle + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid SimpleTriangle +*/ + +export const frontendRender: FrontendRenderFunc = async (opts): Promise => { + try { + opts.container.style.height = `${window.innerHeight}px`; + const bgColor = colord(getComputedStyle(document.body).backgroundColor).toRgb(); + const primaryColor = colord(getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()).toRgb(); + const viewer = new OV.EmbeddedViewer(opts.container, { + backgroundColor: new OV.RGBAColor(bgColor.r, bgColor.g, bgColor.b, 255), + defaultColor: new OV.RGBColor(primaryColor.r, primaryColor.g, primaryColor.b), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + const blob = new Blob([opts.contentBytes()]); + const file = new File([blob], basename(opts.treePath)); + viewer.LoadModelFromFileList([file]); + return true; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/inplace-pdf-viewer.ts similarity index 69% rename from web_src/js/render/plugins/pdf-viewer.ts rename to web_src/js/render/plugins/inplace-pdf-viewer.ts index c7040e96ef1..7447f38ec4e 100644 --- a/web_src/js/render/plugins/pdf-viewer.ts +++ b/web_src/js/render/plugins/inplace-pdf-viewer.ts @@ -1,6 +1,6 @@ -import type {FileRenderPlugin} from '../plugin.ts'; +import type {InplaceRenderPlugin} from '../plugin.ts'; -export function newRenderPluginPdfViewer(): FileRenderPlugin { +export function newInplacePluginPdfViewer(): InplaceRenderPlugin { return { name: 'pdf-viewer', @@ -11,6 +11,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin { async render(container: HTMLElement, fileUrl: string): Promise { const PDFObject = await import('pdfobject'); // TODO: the PDFObject library does not support dynamic height adjustment, + // TODO: it seems that this render must be an inplace render, because the URL must be accessible from the current context container.style.height = `${window.innerHeight - 100}px`; if (!PDFObject.default.embed(fileUrl, container)) { throw new Error('Unable to render the PDF file'); diff --git a/web_src/js/render/swagger.ts b/web_src/js/render/swagger.ts new file mode 100644 index 00000000000..27e678f34bf --- /dev/null +++ b/web_src/js/render/swagger.ts @@ -0,0 +1,54 @@ +// AVOID importing other unneeded main site JS modules to prevent unnecessary code and dependencies and chunks. +// This module is used by both the Gitea API page and the frontend external render. +// It doesn't need any code from main site's modules (at the moment). + +import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; +import {load as loadYaml} from 'js-yaml'; + +function syncDarkModeClass(): void { + // if the viewer is embedded in an iframe (external render), use the parent's theme (passed via query param) + // otherwise, if it is for Gitea's API, it is a standalone page, use the site's theme (detected from theme CSS variable) + const url = new URL(window.location.href); + const giteaIsDarkTheme = url.searchParams.get('gitea-is-dark-theme') ?? + window.getComputedStyle(document.documentElement).getPropertyValue('--is-dark-theme').trim(); + const isDark = giteaIsDarkTheme ? giteaIsDarkTheme === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.classList.toggle('dark-mode', isDark); +} + +export async function initSwaggerUI(container: HTMLElement, opts: {specText: string}): Promise { + // swagger-ui has built-in dark mode triggered by html.dark-mode class + syncDarkModeClass(); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); + + let spec: any; + const specText = opts.specText.trim(); + if (specText.startsWith('{')) { + spec = JSON.parse(specText); + } else { + spec = loadYaml(specText); + } + + // Make the page's protocol be at the top of the schemes list + const proto = window.location.protocol.slice(0, -1); + if (spec?.schemes) { + spec.schemes.sort((a: string, b: string) => { + if (a === proto) return -1; + if (b === proto) return 1; + return 0; + }); + } + + SwaggerUI({ + spec, + domNode: container, + deepLinking: window.location.protocol !== 'about:', // pushState fails inside about:srcdoc iframes + docExpansion: 'none', + defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete + presets: [ + SwaggerUI.presets.apis, + ], + plugins: [ + SwaggerUI.plugins.DownloadUrl, + ], + }); +} diff --git a/web_src/js/swagger.ts b/web_src/js/swagger.ts index b2f6a61030a..f7a852098a2 100644 --- a/web_src/js/swagger.ts +++ b/web_src/js/swagger.ts @@ -1,70 +1,14 @@ -// AVOID importing other unneeded main site JS modules to prevent unnecessary code and dependencies and chunks. -// -// Swagger JS is standalone because it is also used by external render like "File View -> OpenAPI render", -// and it doesn't need any code from main site's modules (at the moment). -// -// In the future, if there are common utilities needed by both main site and standalone Swagger, -// we can merge this standalone module into "index.ts", do pay attention to the following problems: -// * HINT: SWAGGER-OPENAPI-VIEWER: there are different places rendering the swagger UI. -// * Handle CSS styles carefully for different cases (standalone page, embedded in iframe) -// * Take care of the JS code introduced by "index.ts" and "iife.ts", there might be global variable dependency and event listeners. - +// FIXME: INCORRECT-VITE-MANIFEST-PARSER: it just happens to work for current dependencies +// If this module depends on another one and that one imports "swagger.css", then {{AssetURI "css/swagger.css"}} won't work import '../css/swagger.css'; -import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; -import 'swagger-ui-dist/swagger-ui.css'; -import {load as loadYaml} from 'js-yaml'; +import {initSwaggerUI} from './render/swagger.ts'; -function syncDarkModeClass(): void { - // if the viewer is embedded in an iframe (external render), use the parent's theme (passed via query param) - // otherwise, if it is for Gitea's API, it is a standalone page, use the site's theme (detected from theme CSS variable) - const url = new URL(window.location.href); - const giteaIsDarkTheme = url.searchParams.get('gitea-is-dark-theme') ?? - window.getComputedStyle(document.documentElement).getPropertyValue('--is-dark-theme').trim(); - const isDark = giteaIsDarkTheme ? giteaIsDarkTheme === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches; - document.documentElement.classList.toggle('dark-mode', isDark); -} - -async function initSwaggerUI() { - // swagger-ui has built-in dark mode triggered by html.dark-mode class - syncDarkModeClass(); - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); - - const elSwaggerUi = document.querySelector('#swagger-ui')!; +async function initGiteaAPIViewer() { + const elSwaggerUi = document.querySelector('#swagger-ui')!; const url = elSwaggerUi.getAttribute('data-source')!; - let spec: any; - if (url) { - const res = await fetch(url); // eslint-disable-line no-restricted-globals - spec = await res.json(); - } else { - const elSpecContent = elSwaggerUi.querySelector('.swagger-spec-content')!; - const filename = elSpecContent.getAttribute('data-spec-filename'); - const isJson = filename?.toLowerCase().endsWith('.json'); - spec = isJson ? JSON.parse(elSpecContent.value) : loadYaml(elSpecContent.value); - } - - // Make the page's protocol be at the top of the schemes list - const proto = window.location.protocol.slice(0, -1); - if (spec?.schemes) { - spec.schemes.sort((a: string, b: string) => { - if (a === proto) return -1; - if (b === proto) return 1; - return 0; - }); - } - - SwaggerUI({ - spec, - dom_id: '#swagger-ui', - deepLinking: true, - docExpansion: 'none', - defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete - presets: [ - SwaggerUI.presets.apis, - ], - plugins: [ - SwaggerUI.plugins.DownloadUrl, - ], - }); + const res = await fetch(url); // eslint-disable-line no-restricted-globals + // HINT: SWAGGER-CSS-IMPORT: this is used in the standalone page which already has the related CSS imported by `` + await initSwaggerUI(elSwaggerUi, {specText: await res.text()}); } -initSwaggerUI(); +initGiteaAPIViewer(); From cf3f8e807a4e3cb8d42ab3afa11d552c13b94540 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 18 Apr 2026 16:01:58 +0800 Subject: [PATCH 026/150] Avoid top-level await (#37272) --- .../css/{swagger.css => swagger-render.css} | 28 ------------------ web_src/css/swagger-standalone.css | 29 +++++++++++++++++++ .../plugins/frontend-openapi-swagger.ts | 8 +++-- web_src/js/swagger.ts | 2 +- 4 files changed, 35 insertions(+), 32 deletions(-) rename web_src/css/{swagger.css => swagger-render.css} (52%) create mode 100644 web_src/css/swagger-standalone.css diff --git a/web_src/css/swagger.css b/web_src/css/swagger-render.css similarity index 52% rename from web_src/css/swagger.css rename to web_src/css/swagger-render.css index c20eda7948d..1def667e505 100644 --- a/web_src/css/swagger.css +++ b/web_src/css/swagger-render.css @@ -1,9 +1,5 @@ @import "../../node_modules/swagger-ui-dist/swagger-ui.css"; -body { - margin: 0; -} - html, html body, html .swagger-ui, @@ -15,27 +11,3 @@ html .swagger-ui .scheme-container { html.dark-mode .swagger-ui table.headers td { color: var(--color-text) !important; } - -.swagger-back-link { - color: var(--color-primary); - text-decoration: none; - position: absolute; - top: 1rem; - right: 1.5rem; - display: flex; - align-items: center; -} - -.swagger-back-link:hover { - text-decoration: underline; -} - -.swagger-back-link svg { - color: inherit; - fill: currentcolor; - margin-right: 0.5rem; -} - -.swagger-spec-content { - display: none; -} diff --git a/web_src/css/swagger-standalone.css b/web_src/css/swagger-standalone.css new file mode 100644 index 00000000000..ae36ab49cf5 --- /dev/null +++ b/web_src/css/swagger-standalone.css @@ -0,0 +1,29 @@ +@import "swagger-render.css"; + +body { + margin: 0; +} + +.swagger-back-link { + color: var(--color-primary); + text-decoration: none; + position: absolute; + top: 1rem; + right: 1.5rem; + display: flex; + align-items: center; +} + +.swagger-back-link:hover { + text-decoration: underline; +} + +.swagger-back-link svg { + color: inherit; + fill: currentcolor; + margin-right: 0.5rem; +} + +.swagger-spec-content { + display: none; +} diff --git a/web_src/js/render/plugins/frontend-openapi-swagger.ts b/web_src/js/render/plugins/frontend-openapi-swagger.ts index cc8d3451f24..99410fd496a 100644 --- a/web_src/js/render/plugins/frontend-openapi-swagger.ts +++ b/web_src/js/render/plugins/frontend-openapi-swagger.ts @@ -2,12 +2,14 @@ import type {FrontendRenderFunc} from '../plugin.ts'; import {initSwaggerUI} from '../swagger.ts'; // HINT: SWAGGER-CSS-IMPORT: this import is also necessary when swagger is used as a frontend external render -// It must be on top-level, doesn't work in a function -// Static import doesn't work (it needs to use manifest.json to manually add the CSS file) -await import('../../../css/swagger.css'); +// But it can't share the same CSS file with the standalone page: it triggers our Vite manifest parser's bug +// Although single top-level "await import(css)" can work, it requires es2022. +// Otherwise, single function-level "await import(css)" can't work due to Vite's dependency analysis and bundling. +import '../../../css/swagger-render.css'; export const frontendRender: FrontendRenderFunc = async (opts): Promise => { try { + await import('../../../css/swagger-render.css'); await initSwaggerUI(opts.container, {specText: opts.contentString()}); return true; } catch (error) { diff --git a/web_src/js/swagger.ts b/web_src/js/swagger.ts index f7a852098a2..ee0fd289367 100644 --- a/web_src/js/swagger.ts +++ b/web_src/js/swagger.ts @@ -1,6 +1,6 @@ // FIXME: INCORRECT-VITE-MANIFEST-PARSER: it just happens to work for current dependencies // If this module depends on another one and that one imports "swagger.css", then {{AssetURI "css/swagger.css"}} won't work -import '../css/swagger.css'; +import '../css/swagger-standalone.css'; import {initSwaggerUI} from './render/swagger.ts'; async function initGiteaAPIViewer() { From 98202110beb7dd3ac239953e866914381cb69ec6 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 18 Apr 2026 10:49:40 +0200 Subject: [PATCH 027/150] Upgrade go-git to v5.18.0 (#37268) Fixes GHSA-3xc5-wrhm-f963 (credential exposure on HTTP redirects). --- This PR was written with the help of Claude Opus 4.6 Co-authored-by: Claude (Opus 4.6) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d87bf6840c2..d1aac0db900 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/go-co-op/gocron/v2 v2.20.0 github.com/go-enry/go-enry/v2 v2.9.6 github.com/go-git/go-billy/v5 v5.8.0 - github.com/go-git/go-git/v5 v5.17.2 + github.com/go-git/go-git/v5 v5.18.0 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-redsync/redsync/v4 v4.16.0 github.com/go-sql-driver/mysql v1.9.3 diff --git a/go.sum b/go.sum index ee60fd36e43..547c61d826c 100644 --- a/go.sum +++ b/go.sum @@ -302,8 +302,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= -github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= From 0824610e3975bdd355766d8359e498fb2944bea4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 18 Apr 2026 20:55:01 +0200 Subject: [PATCH 028/150] Remove `SubmitEvent` polyfill (#37276) Remove this obsolete polyfill as per https://github.com/go-gitea/gitea/pull/37270#issuecomment-4273399551. Co-authored-by: Claude (Opus 4.7) --- web_src/js/features/common-fetch-action.ts | 8 ++++---- web_src/js/features/repo-diff.ts | 6 +++--- web_src/js/index.ts | 2 -- web_src/js/utils/dom.ts | 22 ---------------------- 4 files changed, 7 insertions(+), 31 deletions(-) diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index f3decf53b2a..24f4ad2e02f 100644 --- a/web_src/js/features/common-fetch-action.ts +++ b/web_src/js/features/common-fetch-action.ts @@ -1,6 +1,6 @@ import {GET, request} from '../modules/fetch.ts'; import {hideToastsAll, showErrorToast} from '../modules/toast.ts'; -import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts'; +import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts'; import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; import {registerGlobalSelectorFunc} from '../modules/observer.ts'; @@ -146,7 +146,7 @@ async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) { } type SubmitFormFetchActionOpts = { - formSubmitter?: HTMLElement; + formSubmitter?: HTMLElement | null; formData?: FormData; }; @@ -396,10 +396,10 @@ export function initGlobalFetchAction() { // * it has "-header" and "-content" variants to set the header and content of the "confirm modal" // * it can refer an existing modal element by "#the-modal-id" - addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => { + addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el, e) => { // "fetch-action" will use the form's data to send the request e.preventDefault(); - await submitFormFetchAction(el, {formSubmitter: submitEventSubmitter(e)}); + await submitFormFetchAction(el, {formSubmitter: e.submitter}); }); addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => { diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 08e26cfc6d8..500c86fec6a 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -5,7 +5,7 @@ import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts'; import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from './pull-view-file.ts'; import {initImageDiff} from './imagediff.ts'; import {showErrorToast} from '../modules/toast.ts'; -import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts'; +import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts'; import {POST, GET} from '../modules/fetch.ts'; import {createTippy} from '../modules/tippy.ts'; import {invertFileFolding} from './file-fold.ts'; @@ -41,8 +41,8 @@ function initRepoDiffConversationForm() { const formData = new FormData(form); // if the form is submitted by a button, append the button's name and value to the form data - const submitter = submitEventSubmitter(e); - const isSubmittedByButton = (submitter?.nodeName === 'BUTTON') || (submitter?.nodeName === 'INPUT' && submitter.type === 'submit'); + const submitter = e.submitter; + const isSubmittedByButton = submitter instanceof HTMLButtonElement || (submitter instanceof HTMLInputElement && submitter.type === 'submit'); if (isSubmittedByButton && submitter.name) { formData.append(submitter.name, submitter.value); } diff --git a/web_src/js/index.ts b/web_src/js/index.ts index ba40bc9b9e4..c65972e3377 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -45,7 +45,6 @@ import {initCaptcha} from './features/captcha.ts'; import {initRepositoryActionView} from './features/repo-actions.ts'; import {initGlobalTooltips} from './modules/tippy.ts'; import {initGiteaFomantic} from './modules/fomantic.ts'; -import {initSubmitEventPolyfill} from './utils/dom.ts'; import {initRepoIssueList} from './features/repo-issue-list.ts'; import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts'; import {initRepoContributors} from './features/contributors.ts'; @@ -69,7 +68,6 @@ import {initDevtest} from './modules/devtest.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ - initSubmitEventPolyfill, initGiteaFomantic, initGlobalComponent, diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 6833a196c3b..e0c6f351939 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -257,28 +257,6 @@ export function loadElem(el: LoadableElement, src: string) { }); } -// some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter -// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)" -const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined'; - -export function submitEventSubmitter(e: any) { - e = e.originalEvent ?? e; // if the event is wrapped by jQuery, use "originalEvent", otherwise, use the event itself - return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter; -} - -function submitEventPolyfillListener(e: Event) { - const form = (e.target as HTMLElement).closest('form'); - if (!form) return; - form._submitter = (e.target as HTMLElement).closest('button:not([type]), button[type="submit"], input[type="submit"]'); -} - -export function initSubmitEventPolyfill() { - if (!needSubmitEventPolyfill) return; - console.warn(`This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill`); - document.body.addEventListener('click', submitEventPolyfillListener); - document.body.addEventListener('focus', submitEventPolyfillListener); -} - export function isElemVisible(el: HTMLElement): boolean { // Check if an element is visible, equivalent to jQuery's `:visible` pseudo. // This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem" From af31b9d433b43ce7300de347ad8ea6314886766e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 19 Apr 2026 03:32:49 +0800 Subject: [PATCH 029/150] Refactor LDAP tests (#37274) Not really fix #37263, just make things better, and easy to catch more clues if it would fail again. --- modules/testlogger/testlogger.go | 14 +- routers/web/admin/auths.go | 2 +- services/forms/auth_form.go | 1 - tests/integration/auth_ldap_test.go | 295 ++++++++++++-------------- tests/integration/html_helper.go | 4 +- tests/integration/integration_test.go | 2 +- 6 files changed, 151 insertions(+), 167 deletions(-) diff --git a/modules/testlogger/testlogger.go b/modules/testlogger/testlogger.go index 39232a3eed3..217121f604b 100644 --- a/modules/testlogger/testlogger.go +++ b/modules/testlogger/testlogger.go @@ -90,8 +90,8 @@ func (w *testLoggerWriterCloser) Reset() { w.Unlock() } -// Printf takes a format and args and prints the string to os.Stdout -func Printf(format string, args ...any) { +// stdoutPrintf takes a format and args and prints the string to os.Stdout +func stdoutPrintf(format string, args ...any) { if !log.CanColorStdout { for i := range args { if c, ok := args[i].(*log.ColoredValue); ok { @@ -118,20 +118,20 @@ func PrintCurrentTest(t testing.TB, skip ...int) func() { deferHasRun := false t.Cleanup(func() { if !deferHasRun { - Printf("!!! %s defer function hasn't been run but Cleanup is called, usually caused by panic", t.Name()) + stdoutPrintf("!!! %s defer function hasn't been run but Cleanup is called, usually caused by panic\n", t.Name()) } }) - Printf("=== %s (%s:%d)\n", log.NewColoredValue(t.Name()), strings.TrimPrefix(filename, prefix), line) + stdoutPrintf("=== %s (%s:%d)\n", log.NewColoredValue(t.Name()), strings.TrimPrefix(filename, prefix), line) WriterCloser.pushT(t) timeoutChecker := time.AfterFunc(TestTimeout, func() { - Printf("!!! %s ... timeout: %v ... stacktrace:\n%s\n\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestTimeout, getRuntimeStackAll()) + stdoutPrintf("!!! %s ... timeout: %v ... stacktrace:\n%s\n\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestTimeout, getRuntimeStackAll()) }) return func() { deferHasRun = true flushStart := time.Now() slowFlushChecker := time.AfterFunc(TestSlowFlush, func() { - Printf("+++ %s ... still flushing after %v ...\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestSlowFlush) + stdoutPrintf("+++ %s ... still flushing after %v ...\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestSlowFlush) }) if err := queue.GetManager().FlushAll(t.Context(), -1); err != nil { // if panic occurs, then the t.Context() is also cancelled ahead, so here it shows "context canceled" error. @@ -143,7 +143,7 @@ func PrintCurrentTest(t testing.TB, skip ...int) func() { runDuration := time.Since(runStart) flushDuration := time.Since(flushStart) if runDuration > TestSlowRun { - Printf("+++ %s is a slow test (run: %v, flush: %v)\n", log.NewColoredValue(t.Name(), log.Bold, log.FgYellow), runDuration, flushDuration) + stdoutPrintf("+++ %s is a slow test (run: %v, flush: %v)\n", log.NewColoredValue(t.Name(), log.Bold, log.FgYellow), runDuration, flushDuration) } WriterCloser.popT() } diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index cc02ce99996..c718eb34e05 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -440,7 +440,7 @@ func EditAuthSourcePost(ctx *context.Context) { log.Trace("Authentication changed by admin(%s): %d", ctx.Doer.Name, source.ID) ctx.Flash.Success(ctx.Tr("admin.auths.update_success")) - ctx.Redirect(setting.AppSubURL + "/-/admin/auths/" + strconv.FormatInt(form.ID, 10)) + ctx.Redirect(setting.AppSubURL + "/-/admin/auths/" + strconv.FormatInt(source.ID, 10)) } // DeleteAuthSource response for deleting an auth source diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index ad2243be348..651618cfad4 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -14,7 +14,6 @@ import ( // AuthenticationForm form for authentication type AuthenticationForm struct { - ID int64 Type int `binding:"Range(2,7)"` Name string `binding:"Required;MaxSize(30)"` TwoFactorPolicy string diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go index fd740ce8e58..ab4d120b9cb 100644 --- a/tests/integration/auth_ldap_test.go +++ b/tests/integration/auth_ldap_test.go @@ -4,8 +4,10 @@ package integration import ( + "fmt" "net/http" "os" + "strconv" "strings" "testing" @@ -14,7 +16,6 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/translation" @@ -46,12 +47,29 @@ type ldapTestEnv struct { serverPort string } -func prepareLdapTestEnv(t *testing.T) *ldapTestEnv { - if os.Getenv("TEST_LDAP") != "1" { - t.Skip() - return nil - } +func TestAuthLDAP(t *testing.T) { + // To test it locally: + // $ docker run --rm gitea/test-openldap:latest -p 389:389 + // $ TEST_LDAP=1 TEST_LDAP_HOST=localhost make "test-sqlite#TestAuthLDAP" + defer tests.PrepareTestEnv(t)() + t.Run("PreventInvalidGroupTeamMap", testLDAPPreventInvalidGroupTeamMap) + t.Run("AuthChange", testLDAPAuthChange) + t.Run("EmailSignin", testLDAPEmailSignin) + hasRealServer, _ := strconv.ParseBool(os.Getenv("TEST_LDAP")) + if hasRealServer { + t.Run("UserSignin", testLDAPUserSignin) + t.Run("UserSyncWithAttributeUsername", testLDAPUserSyncWithAttributeUsername) + t.Run("UserSyncWithoutAttributeUsername", testLDAPUserSyncWithoutAttributeUsername) + t.Run("UserSyncSSHKeys", testLDAPUserSyncSSHKeys) + t.Run("UserSyncWithGroupFilter", testLDAPUserSyncWithGroupFilter) + + t.Run("GroupTeamSyncAddMember", testLDAPGroupTeamSyncAddMember) + t.Run("GroupTeamSyncRemoveMember", testLDAPGroupTeamSyncRemoveMember) + } +} + +func prepareLdapTestServerEnv() *ldapTestEnv { gitLDAPUsers := []ldapUser{ { UserName: "professor", @@ -117,23 +135,8 @@ func prepareLdapTestEnv(t *testing.T) *ldapTestEnv { } } -type ldapAuthOptions struct { - attributeUID optional.Option[string] // defaults to "uid" - attributeSSHPublicKey string - groupFilter string - groupTeamMap string - groupTeamMapRemoval string -} - -func (te *ldapTestEnv) buildAuthSourcePayload(opts ...ldapAuthOptions) map[string]string { - opt := util.OptionalArg(opts) - // Modify user filter to test group filter explicitly - userFilter := "(&(objectClass=inetOrgPerson)(memberOf=cn=git,ou=people,dc=planetexpress,dc=com)(uid=%s))" - if opt.groupFilter != "" { - userFilter = "(&(objectClass=inetOrgPerson)(uid=%s))" - } - - return map[string]string{ +func (te *ldapTestEnv) buildAuthSourcePayload(m map[string]string) map[string]string { + ret := map[string]string{ "type": "2", "name": "ldap", "host": te.serverHost, @@ -141,107 +144,119 @@ func (te *ldapTestEnv) buildAuthSourcePayload(opts ...ldapAuthOptions) map[strin "bind_dn": "uid=gitea,ou=service,dc=planetexpress,dc=com", "bind_password": "password", "user_base": "ou=people,dc=planetexpress,dc=com", - "filter": userFilter, + "filter": "(&(objectClass=inetOrgPerson)(memberOf=cn=git,ou=people,dc=planetexpress,dc=com)(uid=%s))", "admin_filter": "(memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com)", "restricted_filter": "(uid=leela)", - "attribute_username": util.Iif(opt.attributeUID.Has(), opt.attributeUID.Value(), "uid"), + "attribute_username": "uid", "attribute_name": "givenName", "attribute_surname": "sn", "attribute_mail": "mail", - "attribute_ssh_public_key": opt.attributeSSHPublicKey, + "attribute_ssh_public_key": "", "is_sync_enabled": "on", "is_active": "on", "groups_enabled": "on", "group_dn": "ou=people,dc=planetexpress,dc=com", "group_member_uid": "member", - "group_filter": opt.groupFilter, - "group_team_map": opt.groupTeamMap, - "group_team_map_removal": opt.groupTeamMapRemoval, + "group_filter": "", + "group_team_map": "", + "group_team_map_removal": "", "user_uid": "DN", } + for k, v := range m { + if _, ok := ret[k]; !ok { + panic("invalid key: " + k) + } + ret[k] = v + } + return ret } -func (te *ldapTestEnv) addAuthSource(t *testing.T, opts ...ldapAuthOptions) { +func (te *ldapTestEnv) setupAuthSource(t *testing.T, params map[string]string) { session := loginUser(t, "user1") - req := NewRequestWithValues(t, "POST", "/-/admin/auths/new", te.buildAuthSourcePayload(opts...)) - session.MakeRequest(t, req, http.StatusSeeOther) + existing := &auth_model.Source{Name: params["name"]} + if ok, _ := db.GetEngine(t.Context()).Get(existing); ok { + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/-/admin/auths/%d", existing.ID), params) + session.MakeRequest(t, req, http.StatusSeeOther) + } else { + req := NewRequestWithValues(t, "POST", "/-/admin/auths/new", params) + session.MakeRequest(t, req, http.StatusSeeOther) + } } -func TestLDAPUserSignin(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } - defer tests.PrepareTestEnv(t)() - te.addAuthSource(t) +func testLDAPUserSignin(t *testing.T) { + defer tests.PrintCurrentTest(t)() + te := prepareLdapTestServerEnv() + te.setupAuthSource(t, te.buildAuthSourcePayload(nil)) - u := te.gitLDAPUsers[0] - - session := loginUserWithPassword(t, u.UserName, u.Password) - req := NewRequest(t, "GET", "/user/settings") - resp := session.MakeRequest(t, req, http.StatusOK) - - htmlDoc := NewHTMLParser(t, resp.Body) - - assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name")) - assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name")) - assert.Equal(t, u.Email, htmlDoc.Find("#signed-user-email").Text()) + t.Run("Success", func(t *testing.T) { + u := te.gitLDAPUsers[0] + session := loginUserWithPassword(t, u.UserName, u.Password) + req := NewRequest(t, "GET", "/user/settings") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name")) + assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name")) + assert.Equal(t, u.Email, htmlDoc.Find("#signed-user-email").Text()) + }) + t.Run("Failed", func(t *testing.T) { + u := te.otherLDAPUsers[0] + testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect")) + }) } -func TestLDAPAuthChange(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } - - defer tests.PrepareTestEnv(t)() - te.addAuthSource(t) +func testLDAPAuthChange(t *testing.T) { + defer tests.PrintCurrentTest(t)() + te := prepareLdapTestServerEnv() + te.setupAuthSource(t, te.buildAuthSourcePayload(nil)) session := loginUser(t, "user1") req := NewRequest(t, "GET", "/-/admin/auths") resp := session.MakeRequest(t, req, http.StatusOK) - doc := NewHTMLParser(t, resp.Body) - href, exists := doc.Find("table.table td a").Attr("href") - if !exists { - assert.True(t, exists, "No authentication source found") + respStr := resp.Body.String() + doc := NewHTMLParser(t, strings.NewReader(respStr)) + hrefAuthSource, exists := doc.Find("table.table td a").Attr("href") + if !assert.True(t, exists, "No authentication source found") { + t.Logf("response: %s", respStr) return } - req = NewRequest(t, "GET", href) + req = NewRequest(t, "GET", hrefAuthSource) resp = session.MakeRequest(t, req, http.StatusOK) doc = NewHTMLParser(t, resp.Body) host, _ := doc.Find(`input[name="host"]`).Attr("value") assert.Equal(t, te.serverHost, host) - binddn, _ := doc.Find(`input[name="bind_dn"]`).Attr("value") - assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn) + bindDN, _ := doc.Find(`input[name="bind_dn"]`).Attr("value") + assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", bindDN) - req = NewRequestWithValues(t, "POST", href, te.buildAuthSourcePayload(ldapAuthOptions{groupTeamMapRemoval: "off"})) + req = NewRequestWithValues(t, "POST", hrefAuthSource, te.buildAuthSourcePayload(map[string]string{"group_team_map_removal": "off"})) session.MakeRequest(t, req, http.StatusSeeOther) - req = NewRequest(t, "GET", href) + req = NewRequest(t, "GET", hrefAuthSource) resp = session.MakeRequest(t, req, http.StatusOK) doc = NewHTMLParser(t, resp.Body) host, _ = doc.Find(`input[name="host"]`).Attr("value") assert.Equal(t, te.serverHost, host) - binddn, _ = doc.Find(`input[name="bind_dn"]`).Attr("value") - assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn) + bindDN, _ = doc.Find(`input[name="bind_dn"]`).Attr("value") + assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", bindDN) } -func TestLDAPUserSync(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } +func testLDAPUserSyncWithAttributeUsername(t *testing.T) { + defer tests.PrintCurrentTest(t)() + te := prepareLdapTestServerEnv() + te.setupAuthSource(t, te.buildAuthSourcePayload(map[string]string{"attribute_username": "uid"})) + + // reset user and email table + _, _ = db.GetEngine(t.Context()).Where("`name` != 'user1'").Delete(&user_model.User{}) + _ = db.TruncateBeans(t.Context(), &user_model.EmailAddress{}) + unittest.AssertCount(t, &user_model.User{}, 1) - defer tests.PrepareTestEnv(t)() - te.addAuthSource(t) err := auth.SyncExternalUsers(t.Context(), true) - assert.NoError(t, err) + require.NoError(t, err) // Check if users exists for _, gitLDAPUser := range te.gitLDAPUsers { dbUser, err := user_model.GetUserByName(t.Context(), gitLDAPUser.UserName) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, gitLDAPUser.UserName, dbUser.Name) assert.Equal(t, gitLDAPUser.Email, dbUser.Email) assert.Equal(t, gitLDAPUser.IsAdmin, dbUser.IsAdmin) @@ -255,23 +270,21 @@ func TestLDAPUserSync(t *testing.T) { } } -func TestLDAPUserSyncWithEmptyUsernameAttribute(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } +func testLDAPUserSyncWithoutAttributeUsername(t *testing.T) { + defer tests.PrintCurrentTest(t)() + te := prepareLdapTestServerEnv() + authParams := te.buildAuthSourcePayload(map[string]string{"attribute_username": ""}) + te.setupAuthSource(t, authParams) - defer tests.PrepareTestEnv(t)() - - session := loginUser(t, "user1") - payload := te.buildAuthSourcePayload() - payload["attribute_username"] = "" - req := NewRequestWithValues(t, "POST", "/-/admin/auths/new", payload) - session.MakeRequest(t, req, http.StatusSeeOther) + // reset user and email table + _, _ = db.GetEngine(t.Context()).Where("`name` != 'user1'").Delete(&user_model.User{}) + _ = db.TruncateBeans(t.Context(), &user_model.EmailAddress{}) + unittest.AssertCount(t, &user_model.User{}, 1) + adminSession := loginUser(t, "user1") for _, u := range te.gitLDAPUsers { req := NewRequest(t, "GET", "/-/admin/users?q="+u.UserName) - resp := session.MakeRequest(t, req, http.StatusOK) + resp := adminSession.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) @@ -289,9 +302,7 @@ func TestLDAPUserSyncWithEmptyUsernameAttribute(t *testing.T) { require.NoError(t, auth.SyncExternalUsers(t.Context(), true)) - authSource := unittest.AssertExistsAndLoadBean(t, &auth_model.Source{ - Name: payload["name"], - }) + authSource := unittest.AssertExistsAndLoadBean(t, &auth_model.Source{Name: authParams["name"]}) unittest.AssertCount(t, &user_model.User{ LoginType: auth_model.LDAP, LoginSource: authSource.ID, @@ -305,14 +316,13 @@ func TestLDAPUserSyncWithEmptyUsernameAttribute(t *testing.T) { } } -func TestLDAPUserSyncWithGroupFilter(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } - - defer tests.PrepareTestEnv(t)() - te.addAuthSource(t, ldapAuthOptions{groupFilter: "(cn=git)"}) +func testLDAPUserSyncWithGroupFilter(t *testing.T) { + defer tests.PrintCurrentTest(t)() + te := prepareLdapTestServerEnv() + te.setupAuthSource(t, te.buildAuthSourcePayload(map[string]string{ + "filter": "(&(objectClass=inetOrgPerson)(uid=%s))", + "group_filter": "(cn=git)", + })) // Assert a user not a member of the LDAP group "cn=git" cannot login // This test may look like TestLDAPUserSigninFailed but it is not. @@ -365,64 +375,43 @@ func TestLDAPUserSyncWithGroupFilter(t *testing.T) { } } -func TestLDAPUserSigninFailed(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } - - defer tests.PrepareTestEnv(t)() - te.addAuthSource(t) - - u := te.otherLDAPUsers[0] - testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect")) -} - -func TestLDAPUserSSHKeySync(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } - - defer tests.PrepareTestEnv(t)() - te.addAuthSource(t, ldapAuthOptions{attributeSSHPublicKey: "sshPublicKey"}) +func testLDAPUserSyncSSHKeys(t *testing.T) { + defer tests.PrintCurrentTest(t)() + te := prepareLdapTestServerEnv() + te.setupAuthSource(t, te.buildAuthSourcePayload(map[string]string{"attribute_ssh_public_key": "sshPublicKey"})) require.NoError(t, auth.SyncExternalUsers(t.Context(), true)) // Check if users has SSH keys synced + count := 0 for _, u := range te.gitLDAPUsers { if len(u.SSHKeys) == 0 { continue } - session := loginUserWithPassword(t, u.UserName, u.Password) + count++ + session := loginUserWithPassword(t, u.UserName, u.Password) req := NewRequest(t, "GET", "/user/settings/keys") resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) divs := htmlDoc.doc.Find("#keys-ssh .flex-item .flex-item-body:not(:last-child)") - syncedKeys := make([]string, divs.Length()) for i := 0; i < divs.Length(); i++ { syncedKeys[i] = strings.TrimSpace(divs.Eq(i).Text()) } - assert.ElementsMatch(t, u.SSHKeys, syncedKeys, "Unequal number of keys synchronized for user: %s", u.UserName) } + assert.NotZero(t, count) } -func TestLDAPGroupTeamSyncAddMember(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } - +func testLDAPGroupTeamSyncAddMember(t *testing.T) { defer tests.PrepareTestEnv(t)() - te.addAuthSource(t, ldapAuthOptions{ - groupTeamMap: `{"cn=ship_crew,ou=people,dc=planetexpress,dc=com":{"org26": ["team11"]},"cn=admin_staff,ou=people,dc=planetexpress,dc=com": {"non-existent": ["non-existent"]}}`, - groupTeamMapRemoval: "on", - }) + te := prepareLdapTestServerEnv() + te.setupAuthSource(t, te.buildAuthSourcePayload(map[string]string{ + "group_team_map": `{"cn=ship_crew,ou=people,dc=planetexpress,dc=com":{"org26": ["team11"]},"cn=admin_staff,ou=people,dc=planetexpress,dc=com": {"non-existent": ["non-existent"]}}`, + "group_team_map_removal": "on", + })) org, err := organization.GetOrgByName(t.Context(), "org26") assert.NoError(t, err) team, err := organization.GetTeam(t.Context(), org.ID, "team11") @@ -461,16 +450,14 @@ func TestLDAPGroupTeamSyncAddMember(t *testing.T) { } } -func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } +func testLDAPGroupTeamSyncRemoveMember(t *testing.T) { defer tests.PrepareTestEnv(t)() - te.addAuthSource(t, ldapAuthOptions{ - groupTeamMap: `{"cn=dispatch,ou=people,dc=planetexpress,dc=com": {"org26": ["team11"]}}`, - groupTeamMapRemoval: "on", - }) + te := prepareLdapTestServerEnv() + te.setupAuthSource(t, te.buildAuthSourcePayload(map[string]string{ + "group_team_map": `{"cn=dispatch,ou=people,dc=planetexpress,dc=com": {"org26": ["team11"]}}`, + "group_team_map_removal": "on", + })) + org, err := organization.GetOrgByName(t.Context(), "org26") assert.NoError(t, err) team, err := organization.GetTeam(t.Context(), org.ID, "team11") @@ -499,20 +486,18 @@ func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) { assert.False(t, isMember, "User membership should have been removed from team") } -func TestLDAPPreventInvalidGroupTeamMap(t *testing.T) { - te := prepareLdapTestEnv(t) - if te == nil { - return - } - defer tests.PrepareTestEnv(t)() +func testLDAPPreventInvalidGroupTeamMap(t *testing.T) { + defer tests.PrintCurrentTest(t)() + te := prepareLdapTestServerEnv() session := loginUser(t, "user1") - payload := te.buildAuthSourcePayload(ldapAuthOptions{groupTeamMap: `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`, groupTeamMapRemoval: "off"}) + payload := te.buildAuthSourcePayload(map[string]string{"group_team_map": `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`, "group_team_map_removal": "off"}) req := NewRequestWithValues(t, "POST", "/-/admin/auths/new", payload) session.MakeRequest(t, req, http.StatusOK) // StatusOK = failed, StatusSeeOther = ok } -func TestLDAPEmailSignin(t *testing.T) { +func testLDAPEmailSignin(t *testing.T) { + defer tests.PrintCurrentTest(t)() te := ldapTestEnv{ gitLDAPUsers: []ldapUser{ { @@ -549,7 +534,7 @@ func TestLDAPEmailSignin(t *testing.T) { return result })() defer tests.PrepareTestEnv(t)() - te.addAuthSource(t) + te.setupAuthSource(t, te.buildAuthSourcePayload(nil)) u := te.gitLDAPUsers[0] diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go index b1ae4f40f2f..c2045453e65 100644 --- a/tests/integration/html_helper.go +++ b/tests/integration/html_helper.go @@ -4,7 +4,7 @@ package integration import ( - "bytes" + "io" "testing" "github.com/PuerkitoBio/goquery" @@ -17,7 +17,7 @@ type HTMLDoc struct { } // NewHTMLParser parse html file -func NewHTMLParser(t testing.TB, body *bytes.Buffer) *HTMLDoc { +func NewHTMLParser(t testing.TB, body io.Reader) *HTMLDoc { t.Helper() doc, err := goquery.NewDocumentFromReader(body) assert.NoError(t, err) diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index d60f66e785d..33be7d89cb6 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -404,7 +404,7 @@ func logUnexpectedResponse(t testing.TB, recorder *httptest.ResponseRecorder) { if err != nil { return // probably a non-HTML response } - errMsg := htmlDoc.Find(".ui.negative.message").Text() + errMsg := htmlDoc.Find(".ui.negative.message:not(.tw-hidden)").Text() if len(errMsg) > 0 { t.Log("A flash error message was found:", errMsg) } From f247d7d4e5a38148f02b9a414a18da6fe79d338c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 18 Apr 2026 22:21:21 +0200 Subject: [PATCH 030/150] Enhance GetActionWorkflow to support fallback references (#37189) If a workflow is not in default branch the hooks could not be detected Fixes #37169 Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Giteabot --- services/actions/notifier.go | 7 +- services/convert/action_test.go | 109 ++++++++++++++++++++++++++++++++ services/convert/convert.go | 34 ++++++++-- services/webhook/notifier.go | 7 +- 4 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 services/convert/action_test.go diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 19d6be94207..5f7ee6fcea0 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -5,6 +5,7 @@ package actions import ( "context" + "errors" actions_model "code.gitea.io/gitea/models/actions" issues_model "code.gitea.io/gitea/models/issues" @@ -20,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" notify_service "code.gitea.io/gitea/services/notify" @@ -805,7 +807,10 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep } defer gitRepo.Close() - convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) + if err != nil && errors.Is(err, util.ErrNotExist) { + convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + } if err != nil { log.Error("GetActionWorkflow: %v", err) return diff --git a/services/convert/action_test.go b/services/convert/action_test.go new file mode 100644 index 00000000000..7080fc2f146 --- /dev/null +++ b/services/convert/action_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "fmt" + "strings" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildWorkflowTestRepo creates a temporary git repository for testing GetActionWorkflow. +// The default branch "main" has no workflow files; "feature" and "release-v1" each add their own workflow file. +func buildWorkflowTestRepo(t *testing.T) string { + t.Helper() + ctx := t.Context() + tmpDir := t.TempDir() + + _, _, err := gitcmd.NewCommand("init").WithDir(tmpDir).RunStdString(ctx) + require.NoError(t, err) + + readme := "readme" + featureWF := "on: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n" + releaseWF := "on: [push]\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - run: echo release\n" + + // Build a git fast-import stream: + // :4 = initial commit on main (README.md only) + // :5 = feature branch commit (adds feature workflow) + // :6 = release commit from :4 (adds release workflow, tagged release-v1, not on main) + var sb strings.Builder + fmt.Fprintf(&sb, "blob\nmark :1\ndata %d\n%s\n", len(readme), readme) + fmt.Fprintf(&sb, "blob\nmark :2\ndata %d\n%s\n", len(featureWF), featureWF) + fmt.Fprintf(&sb, "blob\nmark :3\ndata %d\n%s\n", len(releaseWF), releaseWF) + fmt.Fprintf(&sb, "commit refs/heads/main\nmark :4\nauthor Test 1000000000 +0000\ncommitter Test 1000000000 +0000\ndata 14\ninitial commit\nM 100644 :1 README.md\n\n") + fmt.Fprintf(&sb, "commit refs/heads/feature\nmark :5\nauthor Test 1000000001 +0000\ncommitter Test 1000000001 +0000\ndata 12\nadd workflow\nfrom :4\nM 100644 :2 .gitea/workflows/my-workflow.yml\n\n") + fmt.Fprintf(&sb, "reset refs/pull/42/merge\nfrom :5\n\n") + fmt.Fprintf(&sb, "commit refs/heads/main\nmark :6\nauthor Test 1000000002 +0000\ncommitter Test 1000000002 +0000\ndata 16\nrelease workflow\nfrom :4\nM 100644 :3 .gitea/workflows/my-workflow.yml\n\n") + fmt.Fprintf(&sb, "reset refs/tags/release-v1\nfrom :6\n\n") + fmt.Fprintf(&sb, "reset refs/heads/main\nfrom :4\n\n") + fmt.Fprintf(&sb, "done\n") + + _, _, err = gitcmd.NewCommand("fast-import").WithDir(tmpDir).WithStdinBytes([]byte(sb.String())).RunStdString(ctx) + require.NoError(t, err) + + return tmpDir +} + +func TestGetActionWorkflow_FallbackRef(t *testing.T) { + ctx := t.Context() + + repoDir := buildWorkflowTestRepo(t) + + gitRepo, err := git.OpenRepository(ctx, repoDir) + require.NoError(t, err) + defer gitRepo.Close() + + repo := &repo_model.Repository{ + DefaultBranch: "main", + OwnerName: "test-owner", + Name: "test-repo", + Units: []*repo_model.RepoUnit{ + { + Type: unit.TypeActions, + Config: &repo_model.ActionsConfig{}, + }, + }, + } + + t.Run("returns error when workflow only on non-default branch", func(t *testing.T) { + _, err := GetActionWorkflow(ctx, gitRepo, repo, "my-workflow.yml") + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) + }) + + t.Run("returns workflow when found via ref", func(t *testing.T) { + wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/heads/feature")) + require.NoError(t, err) + assert.Equal(t, "my-workflow.yml", wf.ID) + }) + + t.Run("returns workflow when found via pull ref", func(t *testing.T) { + wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/pull/42/merge")) + require.NoError(t, err) + assert.Equal(t, "my-workflow.yml", wf.ID) + assert.Contains(t, wf.HTMLURL, "/src/commit/") + }) + + t.Run("returns workflow with tag link when found via tag ref", func(t *testing.T) { + wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/tags/release-v1")) + require.NoError(t, err) + assert.Equal(t, "my-workflow.yml", wf.ID) + assert.Contains(t, wf.HTMLURL, "/src/tag/release-v1/") + }) + + t.Run("returns error when workflow missing from ref", func(t *testing.T) { + _, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "nonexistent.yml", git.RefName("refs/heads/feature")) + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) + }) +} diff --git a/services/convert/convert.go b/services/convert/convert.go index 71d2ecb3333..f7a207622be 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -387,12 +387,15 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task }, nil } -func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branchName, folder string, entry *git.TreeEntry) *api.ActionWorkflow { +func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, refName git.RefName, folder string, entry *git.TreeEntry) *api.ActionWorkflow { cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), util.PathEscapeSegments(entry.Name())) - workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(branchName), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name())) + workflowRepoURL := fmt.Sprintf("%s/src/commit/%s/%s/%s", repo.HTMLURL(ctx), commit.ID.String(), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name())) + if refWebLinkPath := refName.RefWebLinkPath(); refWebLinkPath != "" { + workflowRepoURL = fmt.Sprintf("%s/src/%s/%s/%s", repo.HTMLURL(ctx), refWebLinkPath, util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name())) + } badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), util.PathEscapeSegments(entry.Name()), url.QueryEscape(repo.DefaultBranch)) // See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow @@ -457,7 +460,7 @@ func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *rep workflows := make([]*api.ActionWorkflow, len(entries)) for i, entry := range entries { - workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, repo.DefaultBranch, folder, entry) + workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, git.RefNameFromBranch(repo.DefaultBranch), folder, entry) } return workflows, nil @@ -469,14 +472,35 @@ func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_ return nil, err } - folder, entries, err := actions.ListWorkflows(defaultBranchCommit) + return getActionWorkflowFromCommit(ctx, repo, defaultBranchCommit, git.RefNameFromBranch(repo.DefaultBranch), workflowID) +} + +func GetActionWorkflowByRef(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string, ref git.RefName) (*api.ActionWorkflow, error) { + if ref == "" { + return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) + } + + refCommitID, err := gitrepo.GetRefCommitID(ref.String()) + if err != nil { + return nil, err + } + refCommit, err := gitrepo.GetCommit(refCommitID) + if err != nil { + return nil, err + } + + return getActionWorkflowFromCommit(ctx, repo, refCommit, ref, workflowID) +} + +func getActionWorkflowFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, refName git.RefName, workflowID string) (*api.ActionWorkflow, error) { + folder, entries, err := actions.ListWorkflows(commit) if err != nil { return nil, err } for _, entry := range entries { if entry.Name() == workflowID { - return getActionWorkflowEntry(ctx, repo, defaultBranchCommit, repo.DefaultBranch, folder, entry), nil + return getActionWorkflowEntry(ctx, repo, commit, refName, folder, entry), nil } } diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 0a5661009e5..2b301d4d583 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -5,6 +5,7 @@ package webhook import ( "context" + "errors" actions_model "code.gitea.io/gitea/models/actions" git_model "code.gitea.io/gitea/models/git" @@ -22,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" notify_service "code.gitea.io/gitea/services/notify" @@ -1032,7 +1034,10 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_ } defer gitRepo.Close() - convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) + if err != nil && errors.Is(err, util.ErrNotExist) { + convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + } if err != nil { log.Error("GetActionWorkflow: %v", err) return From ea6280da75d0f4281a5e2ca61a806f6d97752b3e Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 18 Apr 2026 13:39:25 -0700 Subject: [PATCH 031/150] release notes for 1.26.0 (#37282) Frontend from #37266 --- CHANGELOG.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbdf6d9f78..05c56dc84fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ This changelog goes through the changes that have been made in each release without substantial changes to our git log; to see the highlights of what has been added to each release, please refer to the [blog](https://blog.gitea.com). -## [1.26.0-rc0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0-rc0) - 2026-04-07 +## [1.26.0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0) - 2026-04-17 * BREAKING * Correct swagger annotations for enums, status codes, and notification state (#37030) @@ -30,7 +30,8 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Add summary to action runs view (#36883) * Add user badges (#36752) * Add configurable permissions for Actions automatic tokens (#36173) - * Add per-runner “Disable/Pause” (#36776) + * Add per-runner "Disable/Pause" (#36776) + * Feature non-zipped actions artifacts (action v7 / nodejs / npm v6.2.0) (#36786) * PERFORMANCE * WorkflowDispatch API optionally return runid (#36706) * Add render cache for SVG icons (#36863) @@ -41,6 +42,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Refactor cat-file batch operations and support `--batch-command` approach (#35775) * Use merge tree to detect conflicts when possible (#36400) * ENHANCEMENTS + * Implement logout redirection for reverse proxy auth setups (#36085) (#37171) * Adds option to force update new branch in contents routes (#35592) * Add viewer controller for mermaid (zoom, drag) (#36557) * Add code editor setting dropdowns (#36534) @@ -49,7 +51,6 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Allow configuring default PR base branch (fixes #36412) (#36425) * Add support for RPM Errata (updateinfo.xml) (#37125) * Require additional user confirmation for making repo private (#36959) - * Feature non-zipped actions artifacts (action v7 / nodejs / npm v6.2.0) (#36786) * Add `actions.WORKFLOW_DIRS` setting (#36619) * Avoid opening new tab when downloading actions logs (#36740) * Implements OIDC RP-Initiated Logout (#36724) @@ -67,7 +68,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Refactor storage content-type handling of ServeDirectURL (#36804) * Use "Enable Gravatar" but not "Disable" (#36771) * Use case-insensitive matching for Git error "Not a valid object name" (#36728) - * Add “Copy Source” to markup comment menu (#36726) + * Add "Copy Source" to markup comment menu (#36726) * Change image transparency grid to CSS (#36711) * Add "Run" prefix for unnamed action steps (#36624) * Persist actions log time display settings in `localStorage` (#36623) @@ -139,6 +140,20 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Expose content_version for optimistic locking on issue and PR edits (#37035) * Pass ServeHeaderOptions by value instead of pointer, fine tune httplib tests (#36982) * BUGFIXES + * Frontend iframe renderer framework: 3D models, OpenAPI (#37233) (#37273) + * Fix CODEOWNERS absolute path matching. (#37244) (#37264) + * Swift registry metadata: preserve more JSON fields and accept empty metadata (#37254) (#37261) + * Fix user ssh key exporting and tests (#37256) (#37258) + * Fix team member avatar size and add tooltip (#37253) + * Fix commit title rendering in action run and blame (#37243) (#37251) + * Fix corrupted JSON caused by goccy library (#37214) (#37220) + * Add test for "fetch redirect", add CSS value validation for external render (#37207) (#37216) + * Fix incorrect concurrency check (#37205) (#37215) + * Fix handle missing base branch in PR commits API (#37193) (#37203) + * Fix encoding for Matrix Webhooks (#37190) (#37201) + * Fix handle fork-only commits in compare API (#37185) (#37199) + * Indicate form field readonly via background, fix RunUser config (#37175, #37180) (#37178) + * Report structurally invalid workflows to users (#37116) (#37164) * Fix API not persisting pull request unit config when has_pull_requests is not set (#36718) * Rename CSS variables and improve colorblind themes (#36353) * Hide `add-matcher` and `remove-matcher` from actions job logs (#36520) @@ -232,6 +247,9 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Only turn links to current instance into hash links (#36237) * Fix typos in code comments: doesnt, dont, wont (#36890) * REFACTOR + * Clean up and improve non-gitea js error filter (#37148) (#37155) + * Always show owner/repo name in compare page dropdowns (#37172) (#37200) + * Remove dead CSS rules (#37173) (#37177) * Replace Monaco with CodeMirror (#36764) * Replace CSRF cookie with `CrossOriginProtection` (#36183) * Replace index with id in actions routes (#36842) @@ -289,6 +307,9 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Add e2e reaction test, improve accessibility, enable parallel testing (#37081) * Increase e2e test timeouts on CI to fix flaky tests (#37053) * BUILD + * Upgrade go-git to v5.18.0 (#37269) + * Replace rollup-plugin-license with rolldown-license-plugin (#37130) (#37158) + * Bump min go version to 1.26.2 (#37139) (#37143) * Convert locale files from ini to json format (#35489) * Bump golangci-lint to 2.7.2, enable modernize stringsbuilder (#36180) * Port away from `flake-utils` (#35675) From 0bc2a2836f52d06eb7aa4d730a4e88605c464df9 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sun, 19 Apr 2026 01:01:55 +0000 Subject: [PATCH 032/150] [skip ci] Updated translations via Crowdin --- options/locale/locale_ga-IE.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/options/locale/locale_ga-IE.json b/options/locale/locale_ga-IE.json index 85a69220727..cee97810b65 100644 --- a/options/locale/locale_ga-IE.json +++ b/options/locale/locale_ga-IE.json @@ -269,7 +269,7 @@ "install.lfs_path": "Cosán Fréamh Git LFS", "install.lfs_path_helper": "Stórálfar comhaid a rianóidh Git LFS san eolaire seo. Fág folamh le díchumasú.", "install.run_user": "Rith mar Ainm Úsáideora", - "install.run_user_helper": "An ainm úsáideora an chórais oibriúcháin a ritheann Gitea mar. Tabhair faoi deara go gcaithfidh rochtain a bheith ag an úsáideoir seo ar fhréamhchosán an taisclainne.", + "install.run_user_helper": "Ainm úsáideora an chórais oibriúcháin a ritheann Gitea mar, ní mór rochtain scríofa a bheith aige ar na cosáin sonraí. Braitear an luach seo go huathoibríoch agus ní féidir é a athrú anseo. Chun úsáideoir difriúil a úsáid, atosú Gitea faoin gcuntas sin.", "install.domain": "Fearann ​​Freastalaí", "install.domain_helper": "Seoladh fearainn nó óstach don fhreastalaí.", "install.ssh_port": "Port Freastalaí SSH", @@ -316,7 +316,6 @@ "install.invalid_db_table": "Tá an tábla bunachar sonraí \"%s\" neamhbhailí: %v", "install.invalid_repo_path": "Tá cosán fréimhe an stór neamhbhailí:%v", "install.invalid_app_data_path": "Tá cosán sonraí an aip neamhbhailí:%v", - "install.run_user_not_match": "Ní hé an t-ainm úsáideora 'rith mar' an t-ainm úsáideora reatha: %s -> %s", "install.internal_token_failed": "Theip ar chomhartha inmheánach a ghiniúint:%v", "install.secret_key_failed": "Theip ar an eochair rúnda a ghiniúint:%v", "install.save_config_failed": "Theip ar chumraíocht a shábháil:%v", @@ -2827,7 +2826,7 @@ "org.teams.manage_team_member_prompt": "Déantar baill a bhainistiú trí fhoirne. Cuir úsáideoirí le foireann chun cuireadh a thabhairt dóibh chuig an eagraíocht seo.", "org.teams.update_settings": "Nuashonrú Socruithe", "org.teams.delete_team": "Scrios Foireann", - "org.teams.add_team_member": "Cuir Comhalta Foirne leis", + "org.teams.add_team_member": "Cuir ball foirne leis", "org.teams.invite_team_member": "Tabhair cuireadh chuig %s", "org.teams.invite_team_member.list": "Cuirí ar Feitheamh", "org.teams.delete_team_title": "Scrios Foireann", @@ -3181,6 +3180,8 @@ "admin.auths.oauth2_required_claim_name_helper": "Socraigh an t-ainm seo chun logáil isteach ón bhfoinse seo a shrianadh d'úsáideoirí a bhfuil éileamh acu leis an ainm seo", "admin.auths.oauth2_required_claim_value": "Luach Éilimh Riachtanach", "admin.auths.oauth2_required_claim_value_helper": "Socraigh an luach seo chun logáil isteach ón bhfoinse seo a shrianadh chuig úsáideoirí a bhfuil éileamh acu leis an ainm agus an luach seo", + "admin.auths.open_id_connect_external_id_claim": "Ainm Éilimh Aitheantais Sheachtraigh (Roghnach)", + "admin.auths.open_id_connect_external_id_claim_helper": "Ainm an éilimh le húsáid mar aitheantas seachtrach an úsáideora. Is é \"sub\" an rogha réamhshocraithe. I gcás Azure AD / Entra ID, socraigh é seo go \"oid\" chun leanúnachas a choinneáil agus aistriú á dhéanamh ón soláthraí Azure AD V2. Tabhair faoi deara: éilíonn an t-éileamh \"oid\" go gcuirfí an raon feidhme \"próifíl\" san áireamh sa réimse Scóipe thuas.", "admin.auths.oauth2_group_claim_name": "Ainm éileamh ag soláthar ainmneacha grúpa don fhoinse seo (Roghnach)", "admin.auths.oauth2_full_name_claim_name": "Ainm Iomlán Éilimh Ainm. (Roghnach — má shocraítear é, déanfar ainm iomlán an úsáideora a shioncrónú leis an éileamh seo i gcónaí)", "admin.auths.oauth2_ssh_public_key_claim_name": "Ainm Éilimh Eochrach Phoiblí SSH", From 16bdae53c812247a122976b62c3e6106f19be91b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 19 Apr 2026 09:37:50 +0200 Subject: [PATCH 033/150] Workflow Artifact Info Hover (#37100) Add expiry metadata to action artifacts in the run view and show it on hover. --------- Signed-off-by: Nicolas Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.6) Co-authored-by: wxiaoguang --- models/actions/artifact.go | 3 +- options/locale/locale_en-US.json | 1 + routers/web/devtest/mock_actions.go | 31 ++++++++++------- routers/web/repo/actions/view.go | 14 ++++---- templates/devtest/relative-time.tmpl | 1 + templates/repo/actions/view_component.tmpl | 1 + web_src/css/modules/divider.css | 8 +++++ .../js/components/ActionRunArtifacts.test.ts | 34 +++++++++++++++++++ web_src/js/components/ActionRunArtifacts.ts | 25 ++++++++++++++ web_src/js/components/RepoActionView.vue | 24 ++++++++----- web_src/js/features/repo-actions.ts | 1 + web_src/js/modules/gitea-actions.ts | 5 ++- web_src/js/modules/tippy.ts | 7 +++- web_src/js/utils.test.ts | 13 ++++++- web_src/js/utils.ts | 11 ++++++ web_src/js/utils/testhelper.ts | 8 +++++ 16 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 web_src/js/components/ActionRunArtifacts.test.ts create mode 100644 web_src/js/components/ActionRunArtifacts.ts diff --git a/models/actions/artifact.go b/models/actions/artifact.go index d61afb2aed4..ffadc79661a 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -183,6 +183,7 @@ type ActionArtifactMeta struct { ArtifactName string FileSize int64 Status ArtifactStatus + ExpiredUnix timeutil.TimeStamp } // ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run @@ -191,7 +192,7 @@ func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*Act return arts, db.GetEngine(ctx).Table("action_artifact"). Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired). GroupBy("artifact_name"). - Select("artifact_name, sum(file_size) as file_size, max(status) as status"). + Select("artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix"). Find(&arts) } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 343a672dc01..8efafd5c4b7 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -122,6 +122,7 @@ "unpin": "Unpin", "artifacts": "Artifacts", "expired": "Expired", + "artifact_expires_at": "Expires at %s", "confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?", "archived": "Archived", "concept_system_global": "Global", diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 0fb2a358243..fe12dc3079c 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -67,6 +67,9 @@ func MockActionsView(ctx *context.Context) { func MockActionsRunsJobs(ctx *context.Context) { runID := ctx.PathParamInt64("run") + alignTime := func(v, unit int64) int64 { + return (v + unit) / unit * unit + } resp := &actions.ViewResponse{} resp.State.Run.RepoID = 12345 resp.State.Run.TitleHTML = `mock run title link` @@ -96,24 +99,28 @@ func MockActionsRunsJobs(ctx *context.Context) { }, } resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-a", - Size: 100 * 1024, - Status: "expired", + Name: "artifact-a", + Size: 100 * 1024, + Status: "expired", + ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-b", - Size: 1024 * 1024, - Status: "completed", + Name: "artifact-b", + Size: 1024 * 1024, + Status: "completed", + ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", - Size: 100 * 1024, - Status: "expired", + Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 100 * 1024, + Status: "expired", + ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", - Size: 1024 * 1024, - Status: "completed", + Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 1024 * 1024, + Status: "completed", + ExpiresUnix: 0, }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index f92df685fda..fb4dfa9603d 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -248,9 +248,10 @@ type ViewRequest struct { } type ArtifactsViewItem struct { - Name string `json:"name"` - Size int64 `json:"size"` - Status string `json:"status"` + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` + ExpiresUnix int64 `json:"expiresUnix"` } type ViewResponse struct { @@ -344,9 +345,10 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact } for _, art := range artifacts { artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{ - Name: art.ArtifactName, - Size: art.FileSize, - Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), + Name: art.ArtifactName, + Size: art.FileSize, + Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), + ExpiresUnix: int64(art.ExpiredUnix), }) } return artifactsViewItems, nil diff --git a/templates/devtest/relative-time.tmpl b/templates/devtest/relative-time.tmpl index 041ce49f09f..f4c664e26f3 100644 --- a/templates/devtest/relative-time.tmpl +++ b/templates/devtest/relative-time.tmpl @@ -38,6 +38,7 @@
numeric:
weekday:
with time:
+
minutes:

Threshold

diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 405e9cfb4b1..2cc70e499ad 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -28,6 +28,7 @@ data-locale-status-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}" data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}" data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}" + data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}" data-locale-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}" data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}" data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}" diff --git a/web_src/css/modules/divider.css b/web_src/css/modules/divider.css index a60b7d52cbe..32d03885d35 100644 --- a/web_src/css/modules/divider.css +++ b/web_src/css/modules/divider.css @@ -36,3 +36,11 @@ h4.divider { .divider.divider-text::after { margin-left: .75em; } + +.inline-divider { + display: inline-block; + border-left: 1px solid var(--color-secondary); + overflow: hidden; + width: 1px; + margin: 0 var(--gap-inline); +} diff --git a/web_src/js/components/ActionRunArtifacts.test.ts b/web_src/js/components/ActionRunArtifacts.test.ts new file mode 100644 index 00000000000..358510e8cd7 --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.test.ts @@ -0,0 +1,34 @@ +import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts'; +import {normalizeTestHtml} from '../utils/testhelper.ts'; + +describe('buildArtifactTooltipHtml', () => { + test('active artifact', () => { + const result = buildArtifactTooltipHtml({ + name: 'artifact.zip', + size: 1024 * 1024, + status: 'completed', + expiresUnix: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000, + }, 'Expires at %s (extra)'); + + expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(` +Expires at + + 2026-03-20T12:00:00.000Z + + (extra) + , + 1.0 MiB + +`)); + }); + + test('no expiry', () => { + const result = buildArtifactTooltipHtml({ + name: 'artifact.zip', + size: 512, + status: 'completed', + expiresUnix: 0, + }, 'Expires at %s'); + expect(normalizeTestHtml(result)).toBe(`512 B`); + }); +}); diff --git a/web_src/js/components/ActionRunArtifacts.ts b/web_src/js/components/ActionRunArtifacts.ts new file mode 100644 index 00000000000..ca8f5991620 --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.ts @@ -0,0 +1,25 @@ +import {html} from '../utils/html.ts'; +import {formatBytes} from '../utils.ts'; +import type {ActionsArtifact} from '../modules/gitea-actions.ts'; + +export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLocale: string): string { + const sizeText = formatBytes(artifact.size); + if (artifact.expiresUnix <= 0) { + return html`${sizeText}`; // use the same layout as below + } + + // split so the element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""] + const [prefix, suffix = ''] = expiresAtLocale.split('%s'); + const datetime = new Date(artifact.expiresUnix * 1000).toISOString(); + return html` + + ${prefix} + + ${datetime} + + ${suffix} + , + ${sizeText} + + `; +} diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index ee8b4880029..dbb5426ca78 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -5,7 +5,8 @@ import {toRefs} from 'vue'; import {POST, DELETE} from '../modules/fetch.ts'; import ActionRunSummaryView from './ActionRunSummaryView.vue'; import ActionRunJobView from './ActionRunJobView.vue'; -import {createActionRunViewStore} from "./ActionRunView.ts"; +import {createActionRunViewStore} from './ActionRunView.ts'; +import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts'; defineOptions({ name: 'RepoActionView', @@ -20,7 +21,7 @@ const props = defineProps<{ const locale = props.locale; const store = createActionRunViewStore(props.actionsUrl, props.runId); -const {currentRun: run , runArtifacts: artifacts} = toRefs(store.viewData); +const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData); function cancelRun() { POST(`${run.value.link}/cancel`); @@ -120,18 +121,24 @@ async function deleteArtifact(name: string) {
  • - + {{ artifact.name }} - {{ locale.artifactExpired }} + {{ locale.artifactExpired }}
@@ -251,6 +258,7 @@ async function deleteArtifact(name: string) { .left-list-header { font-size: 13px; + font-weight: var(--font-weight-semibold); color: var(--color-text-light-2); } diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index a3984e40cda..d8b13804ba5 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -33,6 +33,7 @@ export function initRepositoryActionView() { artifactsTitle: el.getAttribute('data-locale-artifacts-title'), areYouSure: el.getAttribute('data-locale-are-you-sure'), artifactExpired: el.getAttribute('data-locale-artifact-expired'), + artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), showTimeStamps: el.getAttribute('data-locale-show-timestamps'), showLogSeconds: el.getAttribute('data-locale-show-log-seconds'), diff --git a/web_src/js/modules/gitea-actions.ts b/web_src/js/modules/gitea-actions.ts index 96b31e4c949..bf7550a0329 100644 --- a/web_src/js/modules/gitea-actions.ts +++ b/web_src/js/modules/gitea-actions.ts @@ -1,5 +1,6 @@ // see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts" export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked'; +export type ActionsArtifactStatus = 'expired' | 'completed'; export type ActionsRun = { repoId: number, @@ -49,5 +50,7 @@ export type ActionsJob = { export type ActionsArtifact = { name: string; - status: string; + size: number; + status: ActionsArtifactStatus; + expiresUnix: number; }; diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index 22eb875c976..c2ca9ab51b0 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import type {Content, Instance, Placement, Props} from 'tippy.js'; import {html} from '../utils/html.ts'; +import {stripTags} from '../utils.ts'; type TippyOpts = { role?: string, @@ -85,6 +86,7 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc role: 'tooltip', theme: 'tooltip', hideOnClick, + allowHTML: target.getAttribute('data-tooltip-render') === 'html', placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start', followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false, ...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}), @@ -127,7 +129,10 @@ function attachLazyTooltip(el: HTMLElement): void { if (!el.hasAttribute('aria-label')) { const content = el.getAttribute('data-tooltip-content'); if (content) { - el.setAttribute('aria-label', content); + const isHtml = el.getAttribute('data-tooltip-render') === 'html'; + let ariaLabelValue = content; + if (isHtml) ariaLabelValue = stripTags(content).replace(/\s+/g, ' ').trim(); + el.setAttribute('aria-label', ariaLabelValue); } } } diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts index dfc498693e7..507334c0b41 100644 --- a/web_src/js/utils.test.ts +++ b/web_src/js/utils.test.ts @@ -1,5 +1,5 @@ import { - dirname, basename, extname, isObject, stripTags, parseIssueHref, + dirname, basename, extname, formatBytes, isObject, stripTags, parseIssueHref, translateMonth, translateDay, blobToDataURI, toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo, urlQueryEscape, @@ -122,6 +122,17 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { expect(new Uint8Array(decodeURLEncodedBase64('YQ=='))).toEqual(uint8array('a')); }); +test('formatBytes', () => { + expect(formatBytes(-1)).toBe('0 B'); + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(1024)).toBe('1.0 KiB'); + expect(formatBytes(1536)).toBe('1.5 KiB'); + expect(formatBytes(10 * 1024)).toBe('10 KiB'); + expect(formatBytes(1024 * 1024)).toBe('1.0 MiB'); + expect(formatBytes(1024 * 1024 * 1024)).toBe('1.0 GiB'); +}); + test('file detection', () => { for (const name of ['a.avif', 'a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) { expect(isImageFile({name})).toBeTruthy(); diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index e812a7b978b..d802dd8e084 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -203,6 +203,17 @@ export function isVideoFile({name, type}: {name?: string, type?: string}): boole return Boolean(/\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/')); } +const byteUnits = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']; + +export function formatBytes(num: number, precision = 2): string { + if (!Number.isFinite(num) || num < 0) return `0 ${byteUnits[0]}`; + if (num < 1024) return `${num} ${byteUnits[0]}`; + const exp = Math.min(Math.floor(Math.log2(num) / 10), byteUnits.length - 1); + const value = num / (1024 ** exp); + const digits = Math.max(0, precision - 1 - Math.floor(Math.log10(value))); + return `${value.toFixed(digits)} ${byteUnits[exp]}`; +} + export function toggleFullScreen(fullScreenEl: HTMLElement, isFullScreen: boolean, sourceParentSelector?: string): void { // hide other elements const headerEl = document.querySelector('#navbar')!; diff --git a/web_src/js/utils/testhelper.ts b/web_src/js/utils/testhelper.ts index 9541ecdefb2..8da77d8134e 100644 --- a/web_src/js/utils/testhelper.ts +++ b/web_src/js/utils/testhelper.ts @@ -20,3 +20,11 @@ export function dedent(str: string) { return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim(); } + +export function normalizeTestHtml(s: string) { + const lines = s.replace(/>\s+\n<').trim().split('\n'); + for (let i = 0; i < lines.length; i++) { + lines[i] = lines[i].trim(); + } + return lines.join('\n'); +} From c98134033a77df04f480062988ebc74af7b363df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:20:49 +0200 Subject: [PATCH 034/150] Update Nix flake (#37284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated changes by the [update-flake-lock](https://github.com/DeterminateSystems/update-flake-lock) GitHub Action. ``` Flake lock file updates: • Updated input 'nixpkgs': 'github:nixos/nixpkgs/4c1018d' (2026-04-09) → 'github:nixos/nixpkgs/4bd9165' (2026-04-14) ``` ### Running GitHub Actions on this PR GitHub Actions will not run workflows on pull requests which are opened by a GitHub Action. **To run GitHub Actions workflows on this PR, close and re-open this pull request.** Co-authored-by: github-actions[bot] Co-authored-by: Nicolas --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 8ec14d28526..2130399c1dd 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1775710090, - "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", "owner": "nixos", "repo": "nixpkgs", - "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", "type": "github" }, "original": { From 30be22f30f0dde56c073a4a2a6722f86de79f5b7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:57:48 +0800 Subject: [PATCH 035/150] Refactor frontend `tw-justify-between` layouts to `flex-left-right` (#37291) This PR standardizes left/right two-child frontend layouts on `flex-left-right` and removes ad-hoc `tw-justify-between` combinations. The goal is consistent wrapping + spacing behavior under narrow widths with less utility-class churn. Also: remove useless "flex-center-wrap", slightly improve some templates (no visual change, tested) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wxiaoguang <2114189+wxiaoguang@users.noreply.github.com> Co-authored-by: wxiaoguang --- templates/org/team/sidebar.tmpl | 2 +- templates/org/team/teams.tmpl | 4 ++-- templates/projects/list.tmpl | 2 +- templates/repo/actions/list.tmpl | 2 +- templates/repo/actions/workflow_dispatch.tmpl | 2 +- templates/repo/blame.tmpl | 2 +- templates/repo/branch/list.tmpl | 2 +- templates/repo/commits_table.tmpl | 2 +- templates/repo/diff/box.tmpl | 2 +- templates/repo/editor/edit.tmpl | 2 +- templates/repo/editor/patch.tmpl | 2 +- templates/repo/issue/filter_actions.tmpl | 3 +-- templates/repo/issue/sidebar/due_date.tmpl | 2 +- templates/repo/issue/sidebar/issue_dependencies.tmpl | 10 +++++----- templates/repo/issue/view_content/conversation.tmpl | 2 +- templates/repo/pulls/status.tmpl | 2 +- templates/repo/release/list.tmpl | 2 +- templates/repo/settings/githook_edit.tmpl | 2 +- templates/repo/settings/webhook/history.tmpl | 2 +- templates/repo/view_file.tmpl | 4 ++-- templates/repo/wiki/new.tmpl | 2 +- templates/repo/wiki/pages.tmpl | 2 +- templates/repo/wiki/revision.tmpl | 2 +- templates/shared/searchbottom.tmpl | 2 +- templates/user/notification/notification_div.tmpl | 2 +- .../notification/notification_subscriptions.tmpl | 6 ++---- web_src/css/base.css | 12 ++---------- web_src/js/components/RepoCodeFrequency.vue | 2 +- web_src/js/components/RepoContributors.vue | 2 +- web_src/js/components/RepoRecentCommits.vue | 2 +- web_src/js/features/repo-issue-content.ts | 2 +- 31 files changed, 39 insertions(+), 50 deletions(-) diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index 645c94d4162..1487c280dee 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -1,7 +1,7 @@

{{.Team.Name}} -
+
{{if .Team.IsMember ctx $.SignedUser.ID}} diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 489590cd4df..8bdefa5d43e 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -11,7 +11,7 @@ {{end}} {{end}}
-

+

{{template "repo/file_info" .}}
diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl index 5ae33935758..40aefe5b113 100644 --- a/templates/repo/branch/list.tmpl +++ b/templates/repo/branch/list.tmpl @@ -70,7 +70,7 @@

{{end}} -

+

{{ctx.Locale.Tr "repo.branches"}}
diff --git a/templates/repo/commits_table.tmpl b/templates/repo/commits_table.tmpl index 8f6e6e01692..56a4867ff4b 100644 --- a/templates/repo/commits_table.tmpl +++ b/templates/repo/commits_table.tmpl @@ -1,4 +1,4 @@ -

+

{{if or .PageIsCommits (gt .CommitCount 0)}} {{.CommitCount}} {{ctx.Locale.Tr "repo.commits.commits"}} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index ffba4cf1521..ccb9e80f28b 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -211,7 +211,7 @@ {{if .Diff.IsIncomplete}}
-

+

{{ctx.Locale.Tr "repo.diff.too_many_files"}} {{ctx.Locale.Tr "repo.diff.show_more"}}

diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index bb258d333d1..74d6dcb07f8 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -18,7 +18,7 @@ {{if not .NotEditableReason}}
-
+
-
+
diff --git a/templates/repo/issue/filter_actions.tmpl b/templates/repo/issue/filter_actions.tmpl index 8e2410393d8..4c9366a645b 100644 --- a/templates/repo/issue/filter_actions.tmpl +++ b/templates/repo/issue/filter_actions.tmpl @@ -29,7 +29,7 @@
{{end}} {{$previousExclusiveScope = $exclusiveScope}} -
+
{{if SliceUtils.Contains $.SelLabelIDs .ID}}{{svg (Iif $exclusiveScope "octicon-dot-fill" "octicon-check")}}{{end}} {{ctx.RenderUtils.RenderLabel .}} {{template "repo/issue/labels/label_archived" .}}
@@ -125,4 +125,3 @@
{{end}}
- diff --git a/templates/repo/issue/sidebar/due_date.tmpl b/templates/repo/issue/sidebar/due_date.tmpl index 0e3d57eb728..b312e8a889e 100644 --- a/templates/repo/issue/sidebar/due_date.tmpl +++ b/templates/repo/issue/sidebar/due_date.tmpl @@ -2,7 +2,7 @@ {{ctx.Locale.Tr "repo.issues.due_date"}}
{{if .Issue.DeadlineUnix}} -
+
{{svg "octicon-calendar"}} {{DateUtils.AbsoluteLong .Issue.DeadlineUnix}}
diff --git a/templates/repo/issue/sidebar/issue_dependencies.tmpl b/templates/repo/issue/sidebar/issue_dependencies.tmpl index 0eb7f26c706..f1555dfa397 100644 --- a/templates/repo/issue/sidebar/issue_dependencies.tmpl +++ b/templates/repo/issue/sidebar/issue_dependencies.tmpl @@ -20,7 +20,7 @@
{{range .BlockingDependencies}} -
+
{{end}} {{if .BlockingDependenciesNotPermitted}} -
+
{{ctx.Locale.TrN (len .BlockingDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockingDependenciesNotPermitted)}}
{{end}} @@ -54,7 +54,7 @@
{{range .BlockedByDependencies}} -
+
#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}} @@ -76,7 +76,7 @@ {{end}} {{if $.CanCreateIssueDependencies}} {{range .BlockedByDependenciesNotPermitted}} -
+
{{svg "octicon-lock" 16}} @@ -100,7 +100,7 @@
{{end}} {{else if .BlockedByDependenciesNotPermitted}} -
+
{{ctx.Locale.TrN (len .BlockedByDependenciesNotPermitted) "repo.issues.dependency.no_permission_1" "repo.issues.dependency.no_permission_n" (len .BlockedByDependenciesNotPermitted)}}
{{end}} diff --git a/templates/repo/issue/view_content/conversation.tmpl b/templates/repo/issue/view_content/conversation.tmpl index 333d120fde7..df383f46b87 100644 --- a/templates/repo/issue/view_content/conversation.tmpl +++ b/templates/repo/issue/view_content/conversation.tmpl @@ -11,7 +11,7 @@ The variables in "ctx.Data" are different in each case, making this template fra {{$hasReview := and $comment.Review}} {{$isReviewPending := and $hasReview (eq $comment.Review.Type 0)}}
-
+
{{if and $statusCheckData $statusCheckData.RequireApprovalRunCount}} -
+
{{ctx.Locale.Tr "repo.pulls.status_checks_need_approvals" $statusCheckData.RequireApprovalRunCount}} diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 90ad32bcf05..1009d224372 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -30,7 +30,7 @@ {{end}}
-
+

{{if $.PageIsSingleTag}}{{$release.Title}}{{else}}{{$release.Title}}{{end}} {{template "repo/commit_statuses" dict "Status" $info.CommitStatus "Statuses" $info.CommitStatuses "AdditionalClasses" "tw-flex"}} diff --git a/templates/repo/settings/githook_edit.tmpl b/templates/repo/settings/githook_edit.tmpl index 8c07ed0fb45..b5374032983 100644 --- a/templates/repo/settings/githook_edit.tmpl +++ b/templates/repo/settings/githook_edit.tmpl @@ -1,7 +1,7 @@ {{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings edit githook")}}
-

+

{{.Hook.Name}}
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}} diff --git a/templates/repo/settings/webhook/history.tmpl b/templates/repo/settings/webhook/history.tmpl index 72e204ebd37..797ce05a85b 100644 --- a/templates/repo/settings/webhook/history.tmpl +++ b/templates/repo/settings/webhook/history.tmpl @@ -17,7 +17,7 @@
{{range .History}}
-
+
{{if .IsSucceed}} {{svg "octicon-check"}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 59243bf6840..9f936afb8e0 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -13,7 +13,7 @@ {{end}} {{if not .ReadmeInList}} -
+
{{template "repo/latest_commit" .}} {{if .LatestCommit}} {{if .LatestCommit.Committer}} @@ -25,7 +25,7 @@
{{end}} -

+

{{if .ReadmeInList}} {{svg "octicon-book" 16 "tw-mr-2"}} diff --git a/templates/repo/wiki/new.tmpl b/templates/repo/wiki/new.tmpl index 82c4e2f8ac0..c09b8759ddc 100644 --- a/templates/repo/wiki/new.tmpl +++ b/templates/repo/wiki/new.tmpl @@ -3,7 +3,7 @@ {{template "repo/header" .}}
{{template "base/alert" .}} -
+
{{ctx.Locale.Tr "repo.wiki.new_page"}} {{if .PageIsWikiEdit}} {{ctx.Locale.Tr "repo.wiki.new_page_button"}} diff --git a/templates/repo/wiki/pages.tmpl b/templates/repo/wiki/pages.tmpl index 120c1cda323..efa97103cc4 100644 --- a/templates/repo/wiki/pages.tmpl +++ b/templates/repo/wiki/pages.tmpl @@ -2,7 +2,7 @@
{{template "repo/header" .}}
-

+

{{ctx.Locale.Tr "repo.wiki.pages"}} {{if and .CanWriteWiki (not .Repository.IsMirror)}} diff --git a/templates/repo/wiki/revision.tmpl b/templates/repo/wiki/revision.tmpl index 108e3789378..c59047a6404 100644 --- a/templates/repo/wiki/revision.tmpl +++ b/templates/repo/wiki/revision.tmpl @@ -3,7 +3,7 @@ {{template "repo/header" .}} {{$title := .title}}
-
+
{{svg "octicon-home"}}
diff --git a/templates/shared/searchbottom.tmpl b/templates/shared/searchbottom.tmpl index 4e0bd9570ba..b6a5f1b955c 100644 --- a/templates/shared/searchbottom.tmpl +++ b/templates/shared/searchbottom.tmpl @@ -1,5 +1,5 @@ {{if or .result.Language (not .result.UpdatedUnix.IsZero)}} -
+
{{if .result.Language}} {{.result.Language}} diff --git a/templates/user/notification/notification_div.tmpl b/templates/user/notification/notification_div.tmpl index 8a28f8dc620..a724ab725dd 100644 --- a/templates/user/notification/notification_div.tmpl +++ b/templates/user/notification/notification_div.tmpl @@ -3,7 +3,7 @@ {{$statusUnread := 1}}{{$statusRead := 2}}{{$statusPinned := 3}} {{$notificationUnreadCount := call .PageGlobalData.GetNotificationUnreadCount}} {{$pageTypeIsRead := eq $.PageType "read"}} -
+
{{if eq .Status 1}} -
+
-
- -
{{if not .Issues}} diff --git a/web_src/css/base.css b/web_src/css/base.css index 49ce6565c78..08033e4a6f0 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -873,7 +873,7 @@ table th[data-sortt-desc] .svg { gap: var(--gap-block); } -/* TODO: use this to replace all existing "flex + justify-between" (there are quite a lot) */ +/* this is useful to make a left-right (e.g.: title .... operations) layout with default gap, and it wrap for small widths */ .flex-left-right { display: flex; flex-wrap: wrap; @@ -883,15 +883,6 @@ table th[data-sortt-desc] .svg { min-width: 0; } -/* TODO: use this to replace all existing "flex + wrap" and (there are quite a lot of) */ -.flex-center-wrap { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--gap-block); - min-width: 0; -} - .ui.list.flex-items-block > .item, .ui.vertical.menu.flex-items-block > .item, .ui.form .field > label.flex-text-block, /* override fomantic "block" style */ @@ -903,6 +894,7 @@ table th[data-sortt-desc] .svg { min-width: 0; } +.flex-left-right > .ui.button, .flex-text-block > .ui.button, .flex-text-inline > .ui.button { margin: 0; /* fomantic buttons have default margin, when we use them in a flex container with gap, we do not need these margins */ diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue index 6cba3c51091..97ce07cc350 100644 --- a/web_src/js/components/RepoCodeFrequency.vue +++ b/web_src/js/components/RepoCodeFrequency.vue @@ -144,7 +144,7 @@ const options: ChartOptions<'line'> = {