diff --git a/cmd/hook.go b/cmd/hook.go index 26b3b56053..f8e964d0c6 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -151,9 +151,6 @@ func (d *delayWriter) WriteString(s string) (n int, err error) { } func (d *delayWriter) Close() error { - if d == nil { - return nil - } stopped := d.timer.Stop() if stopped || d.buf == nil { return nil @@ -163,16 +160,6 @@ func (d *delayWriter) Close() error { return err } -type nilWriter struct{} - -func (n *nilWriter) Write(p []byte) (int, error) { - return len(p), nil -} - -func (n *nilWriter) WriteString(s string) (int, error) { - return len(s), nil -} - func parseGitHookCommitRefLine(line string) (oldCommitID, newCommitID string, refFullName git.RefName, ok bool) { fields := strings.Split(line, " ") if len(fields) != 3 { @@ -227,8 +214,7 @@ Gitea or set your environment appropriately.`, "") total := 0 lastline := 0 - var out io.Writer - out = &nilWriter{} + out := io.Discard if setting.Git.VerbosePush { if setting.Git.VerbosePushDelay > 0 { dWriter := newDelayWriter(os.Stdout, setting.Git.VerbosePushDelay) @@ -350,12 +336,10 @@ Gitea or set your environment appropriately.`, "") return nil } - var out io.Writer - var dWriter *delayWriter - out = &nilWriter{} + out := io.Discard if setting.Git.VerbosePush { if setting.Git.VerbosePushDelay > 0 { - dWriter = newDelayWriter(os.Stdout, setting.Git.VerbosePushDelay) + dWriter := newDelayWriter(os.Stdout, setting.Git.VerbosePushDelay) defer dWriter.Close() out = dWriter } else { @@ -382,101 +366,62 @@ Gitea or set your environment appropriately.`, "") PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)), IsWiki: isWiki, } - oldCommitIDs := make([]string, hookBatchSize) - newCommitIDs := make([]string, hookBatchSize) - refFullNames := make([]git.RefName, hookBatchSize) - count := 0 - total := 0 - wasEmpty := false - masterPushed := false + + oldCommitIDs := make([]string, 0, hookBatchSize) + newCommitIDs := make([]string, 0, hookBatchSize) + refFullNames := make([]git.RefName, 0, hookBatchSize) results := make([]private.HookPostReceiveBranchResult, 0) + defer func() { + hookPrintResults(results) + }() + + processBatch := func() error { + if len(refFullNames) == 0 { + return nil + } + _, _ = fmt.Fprintf(out, " Processing %d references\n", len(refFullNames)) + hookOptions.OldCommitIDs = oldCommitIDs + hookOptions.NewCommitIDs = newCommitIDs + hookOptions.RefFullNames = refFullNames + resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions) + if extra.HasError() { + return fail(ctx, extra.UserMsg, "HookPostReceive failed: %v", extra.Error) + } + results = append(results, resp.Results...) + oldCommitIDs = oldCommitIDs[:0] + newCommitIDs = newCommitIDs[:0] + refFullNames = refFullNames[:0] + return nil + } + scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { - // TODO: support news feeds for wiki + // wiki doesn't need "post-receive" at the moment if isWiki { continue } - var ok bool - oldCommitIDs[count], newCommitIDs[count], refFullNames[count], ok = parseGitHookCommitRefLine(scanner.Text()) + oldCommitID, newCommitID, refFullName, ok := parseGitHookCommitRefLine(scanner.Text()) if !ok { continue } + _, _ = fmt.Fprintf(out, ".") - fmt.Fprintf(out, ".") - commitID, _ := git.NewIDFromString(newCommitIDs[count]) - if refFullNames[count] == git.BranchPrefix+"master" && !commitID.IsZero() && count == total { - masterPushed = true - } - count++ - total++ - - if count >= hookBatchSize { - fmt.Fprintf(out, " Processing %d references\n", count) - hookOptions.OldCommitIDs = oldCommitIDs - hookOptions.NewCommitIDs = newCommitIDs - hookOptions.RefFullNames = refFullNames - resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions) - if extra.HasError() { - _ = dWriter.Close() - hookPrintResults(results) - return fail(ctx, extra.UserMsg, "HookPostReceive failed: %v", extra.Error) + oldCommitIDs = append(oldCommitIDs, oldCommitID) + newCommitIDs = append(newCommitIDs, newCommitID) + refFullNames = append(refFullNames, refFullName) + if len(refFullNames) >= hookBatchSize { + // process and start a new batch + if err := processBatch(); err != nil { + return err } - wasEmpty = wasEmpty || resp.RepoWasEmpty - results = append(results, resp.Results...) - count = 0 } } if err := scanner.Err(); err != nil { - _ = dWriter.Close() - hookPrintResults(results) return fail(ctx, "Hook failed: stdin read error", "scanner error: %v", err) } - - if count == 0 { - if wasEmpty && masterPushed { - // We need to tell the repo to reset the default branch to master - extra := private.SetDefaultBranch(ctx, repoUser, repoName, "master") - if extra.HasError() { - return fail(ctx, extra.UserMsg, "SetDefaultBranch failed: %v", extra.Error) - } - } - fmt.Fprintf(out, "Processed %d references in total\n", total) - - _ = dWriter.Close() - hookPrintResults(results) - return nil - } - - hookOptions.OldCommitIDs = oldCommitIDs[:count] - hookOptions.NewCommitIDs = newCommitIDs[:count] - hookOptions.RefFullNames = refFullNames[:count] - - fmt.Fprintf(out, " Processing %d references\n", count) - - resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions) - if resp == nil { - _ = dWriter.Close() - hookPrintResults(results) - return fail(ctx, extra.UserMsg, "HookPostReceive failed: %v", extra.Error) - } - wasEmpty = wasEmpty || resp.RepoWasEmpty - results = append(results, resp.Results...) - - fmt.Fprintf(out, "Processed %d references in total\n", total) - - if wasEmpty && masterPushed { - // We need to tell the repo to reset the default branch to master - extra := private.SetDefaultBranch(ctx, repoUser, repoName, "master") - if extra.HasError() { - return fail(ctx, extra.UserMsg, "SetDefaultBranch failed: %v", extra.Error) - } - } - _ = dWriter.Close() - hookPrintResults(results) - - return nil + return processBatch() } func hookPrintResults(results []private.HookPostReceiveBranchResult) { diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 4908fdf0cc..13f8e53bcc 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -531,6 +531,10 @@ INTERNAL_TOKEN = ;; ;; The value of the X-Content-Type-Options HTTP header for all responses. Use "unset" to remove the header. ;X_CONTENT_TYPE_OPTIONS = nosniff +;; +;; The value of the general Content-Security-Policy for most web pages. +;; Leave it empty to apply the default policy, or set it to "unset" to disable Content-Security-Policy. +;CONTENT_SECURITY_POLICY_GENERAL = ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2668,19 +2672,21 @@ LEVEL = Info ;FILE_EXTENSIONS = .adoc,.asciidoc ;; External command to render all matching extensions ;RENDER_COMMAND = "asciidoc --out-file=- -" -;; Don't pass the file on STDIN, pass the filename as argument instead. +;; Whether Gitea should write the content into a local temp file for the render command's input. +;; * false: the content will be passed via STDIN to the command. +;; * true: write the content into a local temp file, and pass the temp filename as argument to the command. ;IS_INPUT_FILE = false ;; How the content will be rendered. ;; * sanitized: Sanitize the content and render it inside current page, default to only allow a few HTML tags and attributes. Customized sanitizer rules can be defined in [markup.sanitizer.*] . ;; * no-sanitizer: Disable the sanitizer and render the content inside current page. It's **insecure** and may lead to XSS attack if the content contains malicious code. ;; * iframe: Render the content in a separate standalone page and embed it into current page by iframe. The iframe is in sandbox mode with same-origin disabled, and the JS code are safely isolated from parent page. ;RENDER_CONTENT_MODE = sanitized -;; The sandbox applied to the iframe and Content-Security-Policy header when RENDER_CONTENT_MODE is `iframe`. +;; The sandbox applied to the Content-Security-Policy for the rendered content when RENDER_CONTENT_MODE is `iframe`. ;; It defaults to a safe set of "allow-*" restrictions (space separated). ;; You can also set it by your requirements or use "disabled" to disable the sandbox completely. ;; When set it, make sure there is no security risk: ;; * PDF-only content: generally safe to use "disabled", and it needs to be "disabled" because PDF only renders with no sandbox. -;; * HTML content with JS: if the "RENDER_COMMAND" can guarantee there is no XSS, then it is safe, otherwise, you need to fine tune the "allow-*" restrictions. +;; * HTML content with JS: do not set "allow-same-origin" unless the "RENDER_COMMAND" can guarantee there is no XSS. ;RENDER_CONTENT_SANDBOX = ;; Whether post-process the rendered HTML content, including: ;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters, diff --git a/main.go b/main.go index bdb962f4fc..80b8a51d4a 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,6 @@ import ( "gitea.dev/modules/setting" // register supported doc types - _ "gitea.dev/modules/markup/asciicast" _ "gitea.dev/modules/markup/console" _ "gitea.dev/modules/markup/csv" _ "gitea.dev/modules/markup/markdown" diff --git a/modules/git/gitcmd/command.go b/modules/git/gitcmd/command.go index 029cd907f9..67fb41f46d 100644 --- a/modules/git/gitcmd/command.go +++ b/modules/git/gitcmd/command.go @@ -482,6 +482,17 @@ func (c *Command) Start(ctx context.Context) (retErr error) { c.cmd.Stdout = c.cmdStdout c.cmd.Stdin = c.cmdStdin c.cmd.Stderr = c.cmdStderr + c.cmd.Cancel = func() error { + // Golang's default cmd.Cancel only calls Process.Kill(), but here we need to close the parent pipes together: + // * for some commands like "git --batch-xxx", Windows git might have 2 processes (a wrapper and a real git process) + // * on Windows, if parent process is killed (context canceled), the children process won't be killed, and the pipe handles are still open. + // * if we don't close the parent pipes here, the children process won't exit. + // + // There is no such problem on POSIX, while it won't make things worse by closing the parent pipes also on POSIX. + err := c.cmd.Process.Kill() + c.closePipeFiles(c.parentPipeFiles) + return err + } return c.cmd.Start() } diff --git a/modules/git/ref.go b/modules/git/ref.go index 0c9aeae8c0..7d0bbcbae9 100644 --- a/modules/git/ref.go +++ b/modules/git/ref.go @@ -168,7 +168,7 @@ func (ref RefName) ShortName() string { if ref.IsFor() { return ref.ForBranchName() } - return string(ref) // usually it is a commit ID + return string(ref) // usually it is a commit ID, or "HEAD" } // RefGroup returns the group type of the reference diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index 7e6db9abee..1a93504d97 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -36,25 +36,17 @@ func (repo *Repository) GetCommit(ref string) (*Commit, error) { // GetBranchCommit returns the last commit of given branch. func (repo *Repository) GetBranchCommit(name string) (*Commit, error) { - commitID, err := repo.GetBranchCommitID(name) - if err != nil { - return nil, err - } - return repo.GetCommit(commitID) + return repo.GetCommit(RefNameFromBranch(name).String()) } // GetTagCommit get the commit of the specific tag via name func (repo *Repository) GetTagCommit(name string) (*Commit, error) { - commitID, err := repo.GetTagCommitID(name) - if err != nil { - return nil, err - } - return repo.GetCommit(commitID) + return repo.GetCommit(RefNameFromTag(name).String()) } func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Commit, error) { // File name starts with ':' must be escaped. - if relpath[0] == ':' { + if strings.HasPrefix(relpath, ":") { relpath = `\` + relpath } diff --git a/modules/git/repo_ref.go b/modules/git/repo_ref.go index 5adb1e5735..11235c71b1 100644 --- a/modules/git/repo_ref.go +++ b/modules/git/repo_ref.go @@ -8,6 +8,7 @@ import ( "strings" "gitea.dev/modules/git/gitcmd" + "gitea.dev/modules/setting" "gitea.dev/modules/util" ) @@ -86,8 +87,11 @@ func (repo *Repository) UnstableGuessRefByShortName(shortName string) RefName { commit, err := repo.GetCommit(shortName) if err == nil { commitIDString := commit.ID.String() - if strings.HasPrefix(commitIDString, shortName) { + // make sure the "shortName" is either partial commit ID, or it is HEAD + if strings.HasPrefix(commitIDString, shortName) || shortName == RefNameHead { return RefName(commitIDString) + } else { + setting.PanicInDevOrTesting("abuse of UnstableGuessRefByShortName, queried %s, got %s", shortName, commitIDString) } } return "" diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go index 1af6f4406c..17eabb2aad 100644 --- a/modules/gitrepo/gitrepo.go +++ b/modules/gitrepo/gitrepo.go @@ -40,7 +40,7 @@ type contextKey struct { } // RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it -// The caller must call "defer gitRepo.Close()" +// The caller must call Closer.Close() func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) { reqCtx := reqctx.FromContext(ctx) if reqCtx != nil { diff --git a/modules/markup/asciicast/asciicast.go b/modules/markup/asciicast/asciicast.go deleted file mode 100644 index 665cc8dbc0..0000000000 --- a/modules/markup/asciicast/asciicast.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package asciicast - -import ( - "fmt" - "io" - "net/url" - - "gitea.dev/modules/markup" - "gitea.dev/modules/setting" -) - -func init() { - markup.RegisterRenderer(Renderer{}) -} - -// Renderer implements markup.Renderer for asciicast files. -// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md -type Renderer struct{} - -func (Renderer) Name() string { - return "asciicast" -} - -func (Renderer) FileNamePatterns() []string { - return []string{"*.cast"} -} - -const ( - playerClassName = "asciinema-player-container" - playerSrcAttr = "data-asciinema-player-src" -) - -func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { - return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}} -} - -func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error { - rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s", - setting.AppSubURL, - url.PathEscape(ctx.RenderOptions.Metas["user"]), - url.PathEscape(ctx.RenderOptions.Metas["repo"]), - ctx.RenderOptions.Metas["RefTypeNameSubURL"], - url.PathEscape(ctx.RenderOptions.RelativePath), - ) - return ctx.RenderInternal.FormatWithSafeAttrs(output, `
`, playerClassName, playerSrcAttr, rawURL) -} diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 9a70e8f54b..dc6633dff6 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -48,6 +48,11 @@ func RegisterRenderers() { }, }) + markup.RegisterRenderer(&frontendRenderer{ + name: "asciicast", + patterns: []string{"*.cast"}, + }) + for _, renderer := range setting.ExternalMarkupRenderers { markup.RegisterRenderer(&Renderer{renderer}) } diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index 34fa2715f7..3f7c26c575 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -5,6 +5,7 @@ package external import ( "encoding/base64" + "errors" "io" "unicode/utf8" @@ -54,14 +55,13 @@ func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule { func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { ret.SanitizerDisabled = true ret.DisplayInIframe = true - ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" + ret.ContentSandbox = setting.MarkupRenderDefaultSandbox return ret } func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { if ctx.RenderOptions.StandalonePageOptions == nil { - opts := p.GetExternalRendererOptions() - return markup.RenderIFrame(ctx, &opts, output) + return errors.New("should only be rendered in standalone page") } content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize)) diff --git a/modules/markup/render.go b/modules/markup/render.go index 6f43434485..af6f2c70c3 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -211,11 +211,11 @@ func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.W ctx.RenderOptions.Metas["RefTypeNameSubURL"], util.PathEscapeSegments(ctx.RenderOptions.RelativePath), ) - var extraAttrs template.HTML - if opts.ContentSandbox != "" { - extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox) - } - _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) + + // The render response should always have correct "sandbox" limits (no same-origin), + // otherwise the "render link" direct access can still cause XSS without iframe. + // So here we do not need to set sandbox attribute on the iframe. + _, err := htmlutil.HTMLPrintf(output, ``, src) return err } diff --git a/modules/markup/render_test.go b/modules/markup/render_test.go index 3b89d8485e..abbfff85d1 100644 --- a/modules/markup/render_test.go +++ b/modules/markup/render_test.go @@ -22,10 +22,7 @@ func TestRenderIFrame(t *testing.T) { WithRelativePath("tree-path"). WithMetas(map[string]string{"user": "test-owner", "repo": "test-repo", "RefTypeNameSubURL": "src/branch/master"}) - // the value is read from config RENDER_CONTENT_SANDBOX, empty means "disabled" - ret := render(ctx, ExternalRendererOptions{ContentSandbox: ""}) + // iframe doesn't need sandbox, the sandbox is set in render's response header + ret := render(ctx, ExternalRendererOptions{ContentSandbox: "any"}) assert.Equal(t, ``, ret) - - ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"}) - assert.Equal(t, ``, ret) } diff --git a/modules/private/hook.go b/modules/private/hook.go index cb6cc2f0bd..843288e081 100644 --- a/modules/private/hook.go +++ b/modules/private/hook.go @@ -48,9 +48,7 @@ type SSHLogOption struct { // HookPostReceiveResult represents an individual result from PostReceive type HookPostReceiveResult struct { - Results []HookPostReceiveBranchResult - RepoWasEmpty bool - Err string + Results []HookPostReceiveBranchResult } // HookPostReceiveBranchResult represents an individual branch result from PostReceive diff --git a/modules/repository/push.go b/modules/repository/push.go index 433bbee40a..5260597229 100644 --- a/modules/repository/push.go +++ b/modules/repository/push.go @@ -13,9 +13,12 @@ type PushUpdateOptions struct { PusherName string RepoUserName string RepoName string - RefFullName git.RefName // branch, tag or other name to push - OldCommitID string - NewCommitID string + + // FIXME: this struct's design is not right, the changed commits should be in a separate slice + + RefFullName git.RefName // branch, tag or other name to push + OldCommitID string + NewCommitID string } // IsNewRef return true if it's a first-time push to a branch, tag or etc. diff --git a/modules/setting/markup.go b/modules/setting/markup.go index 5562e01ece..39c59025de 100644 --- a/modules/setting/markup.go +++ b/modules/setting/markup.go @@ -237,6 +237,10 @@ func fileExtensionsToPatterns(sectionName string, extensions []string) []string return patterns } +// MarkupRenderDefaultSandbox only contains a safe set of "sandbox allow" values, it is used to protect users from XSS attack, +// DO NOT USE "allow-same-origin" by default: if there is XSS in rendered content, same-origin makes the frame page can access parent window and send requests with user's credentials. +const MarkupRenderDefaultSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" + func newMarkupRenderer(name string, sec ConfigSection) { if !sec.Key("ENABLED").MustBool(false) { return @@ -269,9 +273,7 @@ func newMarkupRenderer(name string, sec ConfigSection) { renderContentMode = RenderContentModeSanitized } - // ATTENTION! at the moment, only a safe set like "allow-scripts" are allowed for sandbox mode. - // "allow-same-origin" should NEVER be used, it leads to XSS attack: makes the JS in iframe can access parent window's config and send requests with user's credentials. - renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString("allow-scripts allow-popups") + renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString(MarkupRenderDefaultSandbox) if renderContentSandbox == "disabled" { renderContentSandbox = "" } diff --git a/modules/setting/security.go b/modules/setting/security.go index c7f41c8b44..a72bd90214 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -18,6 +18,8 @@ var Security = struct { // TODO: move more settings to this struct in future XFrameOptions string XContentTypeOptions string + + ContentSecurityPolicyGeneral string // it only supports empty (default policy) or "unset", maybe it can support more in the future }{ XFrameOptions: "SAMEORIGIN", XContentTypeOptions: "nosniff", @@ -150,13 +152,12 @@ func loadSecurityFrom(rootCfg ConfigProvider) { SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20) deprecatedSetting(rootCfg, "cors", "X_FRAME_OPTIONS", "security", "X_FRAME_OPTIONS", "v1.26.0") - if sec.HasKey("X_FRAME_OPTIONS") { - Security.XFrameOptions = sec.Key("X_FRAME_OPTIONS").MustString(Security.XFrameOptions) - } else { + if !sec.HasKey("X_FRAME_OPTIONS") { Security.XFrameOptions = rootCfg.Section("cors").Key("X_FRAME_OPTIONS").MustString(Security.XFrameOptions) } - - Security.XContentTypeOptions = sec.Key("X_CONTENT_TYPE_OPTIONS").MustString(Security.XContentTypeOptions) + if err := sec.MapTo(&Security); err != nil { + log.Fatal("Failed to map security settings: %v", err) + } twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String() switch twoFactorAuth { diff --git a/modules/setting/security_test.go b/modules/setting/security_test.go new file mode 100644 index 0000000000..70fdc77d12 --- /dev/null +++ b/modules/setting/security_test.go @@ -0,0 +1,22 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadSecurityFrom(t *testing.T) { + cfg, err := NewConfigProviderFromData(`[security] +X_FRAME_OPTIONS = DENY +X_CONTENT_TYPE_OPTIONS = unset +CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"`) + assert.NoError(t, err) + loadSecurityFrom(cfg) + assert.Equal(t, "DENY", Security.XFrameOptions) + assert.Equal(t, "unset", Security.XContentTypeOptions) + assert.Equal(t, `"script-src *`, Security.ContentSecurityPolicyGeneral) // holy shit ini package bug +} diff --git a/options/locale/locale_zh-CN.json b/options/locale/locale_zh-CN.json index ba52e2e79d..03c6e417ce 100644 --- a/options/locale/locale_zh-CN.json +++ b/options/locale/locale_zh-CN.json @@ -1321,6 +1321,7 @@ "repo.editor.fork_branch_exists": "分支「%s」已存在于您的派生仓库中,请选择一个新的分支名称。", "repo.commits.desc": "浏览代码修改历史", "repo.commits.commits": "次代码提交", + "repo.commits.history_enable_follow_renames": "包含重命名", "repo.commits.no_commits": "没有共同的提交。「%s」和「%s」的历史完全不同。", "repo.commits.nothing_to_compare": "没有差异可显示。", "repo.commits.search.tooltip": "您可以在关键词前加上前缀,如「author:」、「committer:」、「after:」或「before:」,例如「retrin author:Alice before:2019-01-13」。", @@ -2204,10 +2205,10 @@ "repo.settings.trust_model.collaborator.desc": "此仓库中协作者的有效签名将被标记为「可信」(无论它们是否是提交者),签名只符合提交者时将标记为「不可信」,都不匹配时标记为「不匹配」。", "repo.settings.trust_model.committer": "提交者", "repo.settings.trust_model.committer.long": "提交者: 信任与提交者相符的签名(这符合 GitHub 的行为并将强制 Gitea 签名的提交以 Gitea 为提交者)。", - "repo.settings.trust_model.committer.desc": "有效签名只有和提交者相匹配才会被标记为「受信任」,否则它们将被标记为「不匹配」。这强制 Gitea 成为签名提交的提交者,而实际提交者被加上 Co-authored-by: 和 Co-committed-by: 的标记。 默认的 Gitea 密钥必须匹配数据库中的一名用户。", + "repo.settings.trust_model.committer.desc": "有效签名只有和提交者相匹配才会被标记为「受信任」,否则它们将被标记为「不匹配」。这意味着在已签名的提交中,Gitea 必须作为提交者,而实际的提交者则在提交信息中通过 `Co-authored-by:` 字段进行标注。 默认的 Gitea 密钥必须与数据库中的用户相匹配。", "repo.settings.trust_model.collaboratorcommitter": "协作者+提交者", "repo.settings.trust_model.collaboratorcommitter.long": "协作者+提交者:信任协作者同时是提交者的签名", - "repo.settings.trust_model.collaboratorcommitter.desc": "此仓库中协作者的有效签名在他同时是提交者时将被标记为「可信」,签名只匹配了提交者时将标记为「不可信」,都不匹配时标记为「不匹配」。这会强制 Gitea 成为签名者和提交者,实际的提交者将被标记于提交消息结尾处的「Co-Authored-By:」和「Co-Committed-By:」。默认的 Gitea 签名密钥必须匹配数据库中的一个用户密钥。", + "repo.settings.trust_model.collaboratorcommitter.desc": "此仓库中协作者的有效签名在他同时是提交者时将被标记为「受信任」,签名只匹配了提交者时将标记为「不可信」,都不匹配时标记为「不匹配」。这将强制使 Gitea 显示为已签名提交的提交者,而实际提交者则在提交信息中以 `Co-Authored-By:` 尾注的形式标出。默认的 Gitea 密钥必须与数据库中的用户相匹配。", "repo.settings.wiki_delete": "删除百科数据", "repo.settings.wiki_delete_desc": "删除仓库百科数据是永久性的,无法撤消。", "repo.settings.wiki_delete_notices_1": "- 这将永久删除和禁用 %s 的百科。", @@ -2598,6 +2599,9 @@ "repo.diff.review.reject": "请求变更", "repo.diff.review.self_approve": "合并请求作者不能批准自己的合并请求", "repo.diff.committed_by": "提交者", + "repo.diff.coauthored_by": "共同撰写人", + "repo.commits.avatar_stack_and": "和", + "repo.commits.avatar_stack_people": "%d 人", "repo.diff.protected": "受保护的", "repo.diff.image.side_by_side": "双排", "repo.diff.image.swipe": "滑动", @@ -2725,6 +2729,7 @@ "graphs.code_frequency.what": "代码频率", "graphs.contributors.what": "贡献", "graphs.recent_commits.what": "最近的提交", + "graphs.chart_zoom_hint": "拖动:缩放,Shift+拖动:平移,双击:重置缩放", "org.org_name_holder": "组织名称", "org.org_full_name_holder": "组织全名", "org.org_name_helper": "组织名字应该简单明了。", @@ -3772,6 +3777,7 @@ "actions.runs.no_matching_online_runner_helper": "没有匹配 %s 标签的在线运行器", "actions.runs.no_job_without_needs": "工作流必须包含至少一个没有依赖关系的作业。", "actions.runs.no_job": "工作流必须包含至少一个作业", + "actions.runs.invalid_reusable_workflow_uses": "无效的可复用工作流「uses」:%s", "actions.runs.actor": "操作者", "actions.runs.status": "状态", "actions.runs.actors_no_select": "所有操作者", @@ -3792,11 +3798,27 @@ "actions.runs.view_workflow_file": "查看工作流文件", "actions.runs.summary": "摘要", "actions.runs.all_jobs": "所有任务", + "actions.runs.job_summaries": "任务摘要", + "actions.runs.expand_caller_jobs": "显示此可复用工作流调用者的任务", + "actions.runs.collapse_caller_jobs": "隐藏此可复用工作流调用者的任务", "actions.runs.attempt": "尝试", "actions.runs.latest": "最新", "actions.runs.latest_attempt": "最新尝试", "actions.runs.triggered_via": "通过 %s 触发", - "actions.runs.total_duration": "总耗时:", + "actions.runs.rerun_triggered": "重新运行已触发", + "actions.runs.back_to_pull_request": "返回合并请求", + "actions.runs.back_to_workflow": "返回工作流", + "actions.runs.total_duration": "总耗时", + "actions.runs.workflow_dependencies": "工作流依赖项", + "actions.runs.graph_jobs_count_1": "%d 个任务", + "actions.runs.graph_jobs_count_n": "%d 个任务", + "actions.runs.graph_dependencies_count_1": "%d 个依赖项", + "actions.runs.graph_dependencies_count_n": "%d 个依赖项", + "actions.runs.graph_success_rate": "%s 成功", + "actions.runs.graph_zoom_in": "放大(在图上 Ctrl/Cmd + 滚动)", + "actions.runs.graph_zoom_max": "已为 100% 缩放", + "actions.runs.graph_zoom_out": "缩小(在图上 Ctrl/Cmd + 滚动)", + "actions.runs.graph_reset_view": "重置视图", "actions.workflow.disable": "禁用工作流", "actions.workflow.disable_success": "工作流「%s」已成功禁用。", "actions.workflow.enable": "启用工作流", diff --git a/package.json b/package.json index c35457e167..8b313bdfed 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "cropperjs": "1.6.2", "dayjs": "1.11.21", "easymde": "2.21.0", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "idiomorph": "0.7.4", "jquery": "4.0.0", "js-yaml": "4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee94cec7f2..c30124ac03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,7 +94,7 @@ importers: version: 2.6.2 '@vitejs/plugin-vue': specifier: 6.0.7 - version: 6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3)) + version: 6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3)) ansi_up: specifier: 6.0.6 version: 6.0.6 @@ -132,8 +132,8 @@ importers: specifier: 2.21.0 version: 2.21.0 esbuild: - specifier: 0.28.0 - version: 0.28.0 + specifier: 0.28.1 + version: 0.28.1 idiomorph: specifier: 0.7.4 version: 0.7.4 @@ -193,10 +193,10 @@ importers: version: 0.7.2 vite: specifier: 8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0) + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) vite-string-plugin: specifier: 2.0.4 - version: 2.0.4(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)) + version: 2.0.4(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) vue: specifier: 3.5.35 version: 3.5.35(typescript@6.0.3) @@ -257,7 +257,7 @@ importers: version: 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) '@vitest/eslint-plugin': specifier: 1.6.19 - version: 1.6.19(@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0))) + version: 1.6.19(@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))) eslint: specifier: 10.4.1 version: 10.4.1(jiti@2.7.0) @@ -347,7 +347,7 @@ importers: version: 17.17.3 vitest: specifier: 4.1.8 - version: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)) + version: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) vue-tsc: specifier: 3.3.3 version: 3.3.3(typescript@6.0.3) @@ -592,158 +592,158 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2498,8 +2498,8 @@ packages: es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -5352,82 +5352,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.28.0': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.28.0': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.28.0': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.28.0': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.28.0': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.28.0': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.28.0': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.28.0': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.28.0': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.28.0': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.28.0': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.28.0': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.28.0': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.28.0': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.28.0': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.28.0': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.28.0': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.4.1(jiti@2.7.0))': @@ -6358,13 +6358,13 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) vue: 3.5.35(typescript@6.0.3) - '@vitest/eslint-plugin@1.6.19(@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)))': + '@vitest/eslint-plugin@1.6.19(@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)))': dependencies: '@typescript-eslint/scope-manager': 8.60.1 '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) @@ -6372,7 +6372,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)) + vitest: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -6385,13 +6385,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0))': + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -7371,34 +7371,34 @@ snapshots: es-toolkit@1.47.0: {} - esbuild@0.28.0: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -10019,11 +10019,11 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - vite-string-plugin@2.0.4(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)): + vite-string-plugin@2.0.4(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) - vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0): + vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -10032,14 +10032,14 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.1 - esbuild: 0.28.0 + esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)): + vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -10056,7 +10056,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index 3cb9eac809..e19bee3e7d 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -5,6 +5,7 @@ package private import ( "context" + "errors" "fmt" "net/http" @@ -29,29 +30,8 @@ import ( repo_service "gitea.dev/services/repository" ) -// HookPostReceive updates services and users -func HookPostReceive(ctx *gitea_context.PrivateContext) { - opts := web.GetForm(ctx).(*private.HookOptions) - - // We don't rely on RepoAssignment here because: - // a) we don't need the git repo in this function - // OUT OF DATE: we do need the git repo to sync the branch to the db now. - // b) our update function will likely change the repository in the db so we will need to refresh it - // c) we don't always need the repo - - ownerName := ctx.PathParam("owner") - repoName := ctx.PathParam("repo") - - // defer getting the repository at this point - as we should only retrieve it if we're going to call update - var ( - repo *repo_model.Repository - gitRepo *git.Repository - ) - defer gitRepo.Close() // it's safe to call Close on a nil pointer - +func hookPostReceiveCollectPushUpdates(opts *private.HookOptions, repo *repo_model.Repository) []*repo_module.PushUpdateOptions { updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs)) - wasEmpty := false - for i := range opts.OldCommitIDs { refFullName := opts.RefFullNames[i] @@ -60,151 +40,124 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { // or other less-standard refs spaces are ignored since there // may be a very large number of them). if refFullName.IsBranch() || refFullName.IsTag() { - if repo == nil { - repo = loadRepository(ctx, ownerName, repoName) - if ctx.Written() { - // Error handled in loadRepository - return - } - wasEmpty = repo.IsEmpty - } - option := &repo_module.PushUpdateOptions{ RefFullName: refFullName, OldCommitID: opts.OldCommitIDs[i], NewCommitID: opts.NewCommitIDs[i], PusherID: opts.UserID, PusherName: opts.UserName, - RepoUserName: ownerName, - RepoName: repoName, + RepoUserName: repo.OwnerName, + RepoName: repo.Name, } updates = append(updates, option) - if repo.IsEmpty && (refFullName.BranchName() == "master" || refFullName.BranchName() == "main") { - // put the master/main branch first - // FIXME: It doesn't always work, since the master/main branch may not be the first batch of updates. - // If the user pushes many branches at once, the Git hook will call the internal API in batches, rather than all at once. - // See https://github.com/go-gitea/gitea/blob/cb52b17f92e2d2293f7c003649743464492bca48/cmd/hook.go#L27 - // If the user executes `git push origin --all` and pushes more than 30 branches, the master/main may not be the default branch. - copy(updates[1:], updates) - updates[0] = option + } + } + return updates +} + +func hookPostReceiveSyncDatabaseBranches(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository, updates []*repo_module.PushUpdateOptions) bool { + branchesToSync := make([]*repo_module.PushUpdateOptions, 0, len(updates)) + for _, update := range updates { + if !update.RefFullName.IsBranch() { + continue + } + if update.IsDelRef() { + if err := git_model.MarkBranchAsDeleted(ctx, repo.ID, update.RefFullName.BranchName(), update.PusherID); err != nil { + ctx.PrivateError(http.StatusInternalServerError, err, fmt.Sprintf("failed to mark branch %s as deleted", update.RefFullName)) + return false } + } else { + branchesToSync = append(branchesToSync, update) + // TODO: should we return the error and return the error when pushing? Currently it will log the error and not prevent the pushing + pull_service.UpdatePullsRefs(ctx, repo, update) } } - if repo != nil && len(updates) > 0 { - branchesToSync := make([]*repo_module.PushUpdateOptions, 0, len(updates)) - for _, update := range updates { - if !update.RefFullName.IsBranch() { - continue - } - if repo == nil { - repo = loadRepository(ctx, ownerName, repoName) - if ctx.Written() { - return - } - wasEmpty = repo.IsEmpty - } - - if update.IsDelRef() { - if err := git_model.MarkBranchAsDeleted(ctx, repo.ID, update.RefFullName.BranchName(), update.PusherID); err != nil { - log.Error("Failed to mark branch as deleted: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to mark branch as deleted: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - } else { - branchesToSync = append(branchesToSync, update) - - // TODO: should we return the error and return the error when pushing? Currently it will log the error and not prevent the pushing - pull_service.UpdatePullsRefs(ctx, repo, update) - } - } - if len(branchesToSync) > 0 { - var err error - gitRepo, err = gitrepo.OpenRepository(ctx, repo) - if err != nil { - log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - - var ( - branchNames = make([]string, 0, len(branchesToSync)) - commitIDs = make([]string, 0, len(branchesToSync)) - ) - for _, update := range branchesToSync { - branchNames = append(branchNames, update.RefFullName.BranchName()) - commitIDs = append(commitIDs, update.NewCommitID) - } - - if err := repo_service.SyncBranchesToDB(ctx, repo.ID, opts.UserID, branchNames, commitIDs, gitRepo.GetCommit); err != nil { - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to sync branch to DB in repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - } - - if err := repo_service.PushUpdates(updates); err != nil { - log.Error("Failed to Update: %s/%s Total Updates: %d", ownerName, repoName, len(updates)) - for i, update := range updates { - log.Error("Failed to Update: %s/%s Update: %d/%d: Branch: %s", ownerName, repoName, i, len(updates), update.RefFullName.BranchName()) - } - log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) - - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } + if len(branchesToSync) == 0 { + return true } + gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) + if err != nil { + ctx.PrivateError(http.StatusInternalServerError, err, "failed to open repository") + return false + } + + branchNames := make([]string, 0, len(branchesToSync)) + commitIDs := make([]string, 0, len(branchesToSync)) + for _, update := range branchesToSync { + branchNames = append(branchNames, update.RefFullName.BranchName()) + commitIDs = append(commitIDs, update.NewCommitID) + } + + if err = repo_service.SyncBranchesToDB(ctx, repo.ID, opts.UserID, branchNames, commitIDs, gitRepo.GetCommit); err != nil { + ctx.PrivateError(http.StatusInternalServerError, err, "failed to sync branch to DB") + return false + } + return true +} + +// HookPostReceive updates services and users +func HookPostReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + if opts.IsWiki { + setting.PanicInDevOrTesting("wiki hook-post-receive is not supported") + return + } + + ownerName := ctx.PathParam("owner") + repoName := ctx.PathParam("repo") + repo := loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + return + } + // now, repo can't be nil + + // first, collect updates and sync branches + updates := hookPostReceiveCollectPushUpdates(opts, repo) + if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) { + return + } + hookPostReceiveSyncRepoDefaultBranch(ctx, opts, repo) + // handle pull request merging, a pull request action should push at least 1 commit if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase { - handlePullRequestMerging(ctx, opts, ownerName, repoName, updates) - if ctx.Written() { + if !hookPostReceiveHandlePullRequestMerging(ctx, opts, updates) { return } } + if !hookPostReceiveUpdateRepoByOptions(ctx, opts, repo) { + return + } + + // push async updates + if err := repo_service.PushUpdates(updates...); err != nil { + ctx.PrivateError(http.StatusInternalServerError, err, "failed to push updates") + return + } + + hookPostReceiveRespondWithTrailer(ctx, opts, repo) +} + +func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository) bool { isPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate) isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate) // Handle Push Options if isPrivate.Has() || isTemplate.Has() { - // load the repository - if repo == nil { - repo = loadRepository(ctx, ownerName, repoName) - if ctx.Written() { - // Error handled in loadRepository - return - } - wasEmpty = repo.IsEmpty - } - pusher, err := loadContextCacheUser(ctx, opts.UserID) if err != nil { - log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), - }) - return + ctx.PrivateError(http.StatusInternalServerError, err, "failed to load pusher user") + return false } perm, err := access_model.GetDoerRepoPermission(ctx, repo, pusher) if err != nil { - log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), - }) - return + ctx.PrivateError(http.StatusInternalServerError, err, "failed to load doer repo permission") + return false } if !perm.IsOwner() && !perm.IsAdmin() { - ctx.JSON(http.StatusNotFound, private.HookPostReceiveResult{ - Err: "Permissions denied", - }) - return + ctx.PrivateError(http.StatusNotFound, nil, "permission denied") + return false } // FIXME: these options are not quite right, for example: changing visibility should do more works than just setting the is_private flag @@ -213,22 +166,37 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { // TODO: it needs to do more work repo.IsPrivate = isPrivate.Value() if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to change visibility"}) + log.Error("failed to update repo is_private: %v", err) } } if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() { repo.IsTemplate = isTemplate.Value() if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil { - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to change template status"}) + log.Error("failed to update repo is_template: %v", err) } } } + return true +} +func hookPostReceiveRespondWithTrailer(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository) { results := make([]private.HookPostReceiveBranchResult, 0, len(opts.OldCommitIDs)) + baseRepo := repo + if repo.IsFork { + if err := repo.GetBaseRepo(ctx); err != nil { + ctx.PrivateError(http.StatusInternalServerError, err, "failed to load base repo") + return + } + if repo.BaseRepo.AllowsPulls(ctx) { + baseRepo = repo.BaseRepo + } + } - // We have to reload the repo in case its state is changed above - repo = nil - var baseRepo *repo_model.Repository + if !baseRepo.AllowsPulls(ctx) { + // We can stop there's no need to go any further + ctx.JSON(http.StatusOK, private.HookPostReceiveResult{}) + return + } // Now handle the pull request notification trailers for i := range opts.OldCommitIDs { @@ -237,66 +205,19 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { // If we've pushed a branch (and not deleted it) if !git.IsEmptyCommitID(newCommitID) && refFullName.IsBranch() { - // First ensure we have the repository loaded, we're allowed pulls requests and we can get the base repo - if repo == nil { - repo = loadRepository(ctx, ownerName, repoName) - if ctx.Written() { - return - } - - baseRepo = repo - - if repo.IsFork { - if err := repo.GetBaseRepo(ctx); err != nil { - log.Error("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err), - RepoWasEmpty: wasEmpty, - }) - return - } - if repo.BaseRepo.AllowsPulls(ctx) { - baseRepo = repo.BaseRepo - } - } - - if !baseRepo.AllowsPulls(ctx) { - // We can stop there's no need to go any further - ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ - RepoWasEmpty: wasEmpty, - }) - return - } - } - branch := refFullName.BranchName() - if branch == baseRepo.DefaultBranch { - if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{ - RepoID: repo.ID, - }); err != nil { - ctx.JSON(http.StatusInternalServerError, private.Response{Err: err.Error()}) - return - } - + if branch == baseRepo.DefaultBranch && !repo.IsFork { // If our branch is the default branch of an unforked repo - there's no PR to create or refer to - if !repo.IsFork { - results = append(results, private.HookPostReceiveBranchResult{}) - continue - } + results = append(results, private.HookPostReceiveBranchResult{}) + continue } pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch, issues_model.PullRequestFlowGithub) - if err != nil && !issues_model.IsErrPullRequestNotExist(err) { - log.Error("Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf( - "Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err), - RepoWasEmpty: wasEmpty, - }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.PrivateError(http.StatusInternalServerError, err, "failed to get active PR for branch "+branch) return } - if pr == nil { results = append(results, private.HookPostReceiveBranchResult{ Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(ctx), @@ -314,43 +235,79 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { } } } - ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ - Results: results, - RepoWasEmpty: wasEmpty, - }) + ctx.JSON(http.StatusOK, private.HookPostReceiveResult{Results: results}) } func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) { return cache.GetWithContextCache(ctx, cachegroup.User, id, user_model.GetUserByID) } -// handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit -func handlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, ownerName, repoName string, updates []*repo_module.PushUpdateOptions) { +// hookPostReceiveHandlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit +func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, updates []*repo_module.PushUpdateOptions) bool { if len(updates) == 0 { - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID), - }) - return + err := fmt.Errorf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID) + ctx.PrivateError(http.StatusInternalServerError, err, "no push update") + return false } pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID) if err != nil { - log.Error("GetPullRequestByID[%d]: %v", opts.PullRequestID, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "GetPullRequestByID failed"}) - return + ctx.PrivateError(http.StatusInternalServerError, err, "failed to load pull request") + return false } pusher, err := loadContextCacheUser(ctx, opts.UserID) if err != nil { - log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Load pusher user failed"}) - return + ctx.PrivateError(http.StatusInternalServerError, err, "failed to load pusher user") + return false } // FIXME: Maybe we need a `PullRequestStatusMerged` status for PRs that are merged, currently we use the previous status // here to keep it as before, that maybe PullRequestStatusMergeable - if _, err := pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), pusher, pr.Status); err != nil { - log.Error("Failed to update PR to merged: %v", err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to update PR to merged"}) + _, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), pusher, pr.Status) + if err != nil { + ctx.PrivateError(http.StatusInternalServerError, err, "failed to set pr to merged") + return false + } + return true +} + +func hookPostReceiveSyncRepoDefaultBranch(ctx *gitea_context.PrivateContext, opts *private.HookOptions, repo *repo_model.Repository) { + hasBranch := false + for _, refFullName := range opts.RefFullNames { + if hasBranch = refFullName.IsBranch(); hasBranch { + break + } + } + if !hasBranch { + return + } + gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) + if err != nil { + log.Error("failed to open git repo: %v", err) + return + } + + // if default branch doesn't exist, try to guess one from existing git repo + _, err = gitRepo.GetBranchCommitID(repo.DefaultBranch) + if errors.Is(err, util.ErrNotExist) { + for _, guessBranchName := range []string{"main", "master"} { + if _, err = gitRepo.GetBranchCommitID(guessBranchName); err == nil { + repo.DefaultBranch = guessBranchName + err = repo_model.UpdateDefaultBranch(ctx, repo) + if err != nil { + log.Error("failed to update default branch: %v", err) + return + } + break + } + } + } + + // if default branch was pushed, always keep the HEAD ref in sync + for _, refFullName := range opts.RefFullNames { + if refFullName.IsBranch() && refFullName.BranchName() == repo.DefaultBranch { + _ = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch) + } } } diff --git a/routers/private/hook_post_receive_test.go b/routers/private/hook_post_receive_test.go index b18d9842e8..b465c7f6e8 100644 --- a/routers/private/hook_post_receive_test.go +++ b/routers/private/hook_post_receive_test.go @@ -32,10 +32,10 @@ func TestHandlePullRequestMerging(t *testing.T) { autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID}) ctx, resp := contexttest.MockPrivateContext(t, "/") - handlePullRequestMerging(ctx, &private.HookOptions{ + hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{ PullRequestID: pr.ID, UserID: 2, - }, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, []*repo_module.PushUpdateOptions{ + }, []*repo_module.PushUpdateOptions{ {NewCommitID: "01234567"}, }) assert.Empty(t, resp.Body.String()) diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index aa3ad614c6..f5972c8db0 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -52,13 +52,16 @@ func Branches(ctx *context.Context) { kw := ctx.FormString("q") - defaultBranch, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, optional.None[bool](), kw, page, pageSize) + defaultBranchOptional, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, optional.None[bool](), kw, page, pageSize) if err != nil { ctx.ServerError("LoadBranches", err) return } - commitIDs := []string{defaultBranch.DBBranch.CommitID} + commitIDs := make([]string, 0, len(branches)+1) + if defaultBranchOptional != nil { + commitIDs = append(commitIDs, defaultBranchOptional.DBBranch.CommitID) + } for _, branch := range branches { commitIDs = append(commitIDs, branch.DBBranch.CommitID) } @@ -83,7 +86,7 @@ func Branches(ctx *context.Context) { ctx.Data["Branches"] = branches ctx.Data["CommitStatus"] = commitStatus ctx.Data["CommitStatuses"] = commitStatuses - ctx.Data["DefaultBranchBranch"] = defaultBranch + ctx.Data["DefaultBranchBranch"] = defaultBranchOptional pager := context.NewPagination(branchesCount, pageSize, page, 5) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager @@ -152,7 +155,7 @@ func RestoreBranchPost(ctx *context.Context) { objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName) // Don't return error below this - if err := repo_service.PushUpdate( + if err := repo_service.PushUpdates( &repo_module.PushUpdateOptions{ RefFullName: git.RefNameFromBranch(deletedBranch.Name), OldCommitID: objectFormat.EmptyObjectID().String(), diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index c1c2ed5e86..4ae2955f6d 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -58,8 +58,6 @@ func CorsHandler() func(next http.Handler) http.Handler { // httpBase does the common work for git http services, // including early response, authentication, repository lookup and permission check. func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { - reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git") - if ctx.FormString("go-get") == "1" { context.EarlyResponseForGoGetMeta(ctx) return nil @@ -93,11 +91,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { isWiki := false unitType := unit.TypeCode - - if strings.HasSuffix(reponame, ".wiki") { + repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git") + if strings.HasSuffix(repoName, ".wiki") { isWiki = true unitType = unit.TypeWiki - reponame = reponame[:len(reponame)-5] + repoName = repoName[:len(repoName)-5] } owner := ctx.ContextUser @@ -107,14 +105,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { } repoExist := true - repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame) + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) if err != nil { if !repo_model.IsErrRepoNotExist(err) { ctx.ServerError("GetRepositoryByName", err) return nil } - if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil { + if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName); err == nil { context.RedirectToRepo(ctx.Base, redirectRepoID) return nil } @@ -127,23 +125,26 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } - // Only public pull don't need auth. - isPublicPull := repoExist && !repo.IsPrivate && isPull - askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict - - // don't allow anonymous pulls if organization is not public - if isPublicPull { - if err := repo.LoadOwner(ctx); err != nil { - ctx.ServerError("LoadOwner", err) - return nil + // Only public pulls don't need auth: repo must exist, not require-sign-in + canAnonymousPull := false + if isPull && repoExist && !setting.Service.RequireSignInViewStrict { + // allow anonymous pulls if owner is public and repo is public (not private) + if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate { + canAnonymousPull = true + } + // then check "public anonymous access" permission + if !canAnonymousPull && ctx.Doer == nil { + anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil) + if err != nil { + ctx.ServerError("GetDoerRepoPermission", err) + return nil + } + canAnonymousPull = anonPerm.CanAccess(accessMode, unitType) } - - askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic) } // check access - if askAuth { - // rely on the results of Contexter + if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth if !ctx.IsSigned { // TODO: support digit auth - which would be Authorization header with digit if setting.OAuth2.Enabled { @@ -229,7 +230,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } - repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame) + repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, repoName) if err != nil { log.Error("pushCreateRepo: %v", err) ctx.Status(http.StatusNotFound) diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index 054e63635e..b323da163c 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -63,9 +63,7 @@ func RenderFile(ctx *context.Context) { // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context extRendererOpts := extRenderer.GetExternalRendererOptions() if extRendererOpts.ContentSandbox != "" { - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox) - } else { - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") + ctx.Resp.Header().Add("Content-Security-Policy", "sandbox "+extRendererOpts.ContentSandbox) } err = markup.RenderWithRenderer(rctx, renderer, rendererInput, ctx.Resp) diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index ba4828faf8..d23cca7fa5 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -18,7 +18,6 @@ import ( repo_model "gitea.dev/models/repo" "gitea.dev/models/unit" user_model "gitea.dev/models/user" - "gitea.dev/modules/cache" "gitea.dev/modules/git" "gitea.dev/modules/log" "gitea.dev/modules/optional" @@ -63,22 +62,6 @@ func MustBeAbleToUpload(ctx *context.Context) { } } -func CommitInfoCache(ctx *context.Context) { - var err error - ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) - if err != nil { - ctx.ServerError("GetBranchCommit", err) - return - } - ctx.Repo.CommitsCount, err = ctx.Repo.GetCommitsCount(ctx) - if err != nil { - ctx.ServerError("GetCommitsCount", err) - return - } - ctx.Data["CommitsCount"] = ctx.Repo.CommitsCount - ctx.Repo.GitRepo.LastCommitCache = git.NewLastCommitCache(ctx.Repo.CommitsCount, ctx.Repo.Repository.FullName(), ctx.Repo.GitRepo, cache.GetCache()) -} - func checkContextUser(ctx *context.Context, uid int64) *user_model.User { orgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) if err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index f785d372a4..a933ac1c4d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1482,15 +1482,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/releases", func() { m.Get("/new", repo.NewRelease) m.Post("/new", web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) + m.Get("/edit/*", repo.EditRelease) + m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost) m.Post("/generate-notes", web.Bind(forms.GenerateReleaseNotesForm{}), repo.GenerateReleaseNotes) m.Post("/delete", repo.DeleteRelease) m.Post("/attachments", repo.UploadReleaseAttachment) m.Post("/attachments/remove", repo.DeleteAttachment) }, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter) - m.Group("/releases", func() { - m.Get("/edit/*", repo.EditRelease) - m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost) - }, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache) }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader) // end "/{username}/{reponame}": repo releases diff --git a/services/context/context_template.go b/services/context/context_template.go index 6e085b4ac6..c458912e69 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -115,6 +115,9 @@ func (c TemplateContext) CspScriptNonce() (ret string) { } func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML { + if setting.Security.ContentSecurityPolicyGeneral == "unset" { + return "" // if site admin disables the general CSP, then we don't use it + } // The CSP problem is more complicated than it looks. // Gitea was designed to support various "customizations", including: // * custom themes (custom CSS and JS) diff --git a/services/context/private.go b/services/context/private.go index 2c0d21102a..e687821b07 100644 --- a/services/context/private.go +++ b/services/context/private.go @@ -9,6 +9,7 @@ import ( "time" "gitea.dev/modules/graceful" + "gitea.dev/modules/private" "gitea.dev/modules/process" "gitea.dev/modules/web" web_types "gitea.dev/modules/web/types" @@ -49,6 +50,14 @@ func (ctx *PrivateContext) Err() error { return ctx.Base.Err() } +func (ctx *PrivateContext) PrivateError(status int, err error, userMsg string) { + errMsg := "" + if err != nil { + errMsg = err.Error() + } + ctx.JSON(status, private.Response{Err: errMsg, UserMsg: userMsg}) +} + type privateContextKeyType struct{} var privateContextKey privateContextKeyType diff --git a/services/context/repo.go b/services/context/repo.go index fa816ba6ad..81df87a67f 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -972,12 +972,9 @@ func RepoRefByType(detectRefType git.RefType) func(*Context) { ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refShortName) if err == nil { ctx.Repo.CommitID = ctx.Repo.Commit.ID.String() - } else if strings.Contains(err.Error(), "fatal: not a git repository") || strings.Contains(err.Error(), "object does not exist") { + } else { // if the repository is broken, we can continue to the handler code, to show "Settings -> Delete Repository" for end users log.Error("GetBranchCommit: %v", err) - } else { - ctx.ServerError("GetBranchCommit", err) - return } } else { // there is a path in request guessLegacyPath := refType == "" diff --git a/services/repository/branch.go b/services/repository/branch.go index 9ab485ca71..0869750db4 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -59,9 +59,9 @@ type Branch struct { } // LoadBranches loads branches from the repository limited by page & pageSize. -func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, isDeletedBranch optional.Option[bool], keyword string, page, pageSize int) (*Branch, []*Branch, int64, error) { - defaultDBBranch, err := git_model.GetBranch(ctx, repo.ID, repo.DefaultBranch) - if err != nil { +func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, isDeletedBranch optional.Option[bool], keyword string, page, pageSize int) (defaultBranchOptional *Branch, _ []*Branch, _ int64, _ error) { + defaultDBBranchOptional, err := git_model.GetBranch(ctx, repo.ID, repo.DefaultBranch) + if err != nil && !errors.Is(err, util.ErrNotExist) { return nil, nil, 0, err } @@ -108,13 +108,14 @@ func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git branches = append(branches, branch) } - // Always add the default branch - log.Debug("loadOneBranch: load default: '%s'", defaultDBBranch.Name) - defaultBranch, err := loadOneBranch(ctx, repo, defaultDBBranch, &rules, repoIDToRepo, repoIDToGitRepo) - if err != nil { - return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err) + if defaultDBBranchOptional != nil { + // Always add the default branch + defaultBranchOptional, err = loadOneBranch(ctx, repo, defaultDBBranchOptional, &rules, repoIDToRepo, repoIDToGitRepo) + if err != nil { + return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err) + } } - return defaultBranch, branches, totalNumOfBranches, nil + return defaultBranchOptional, branches, totalNumOfBranches, nil } func getDivergenceCacheKey(repoID int64, branchName string) string { @@ -640,7 +641,7 @@ func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.R func deleteBranchSuccessPostProcess(doer *user_model.User, repo *repo_model.Repository, branchName string, branchCommit *git.Commit) { objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) - if err := PushUpdate( + if err := PushUpdates( &repo_module.PushUpdateOptions{ RefFullName: git.RefNameFromBranch(branchName), OldCommitID: branchCommit.ID.String(), diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index 553f4232e2..bffd352583 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -47,7 +47,8 @@ func NewTemporaryUploadRepository(repo *repo_model.Repository) (*TemporaryUpload // Close the repository cleaning up all files func (t *TemporaryUploadRepository) Close() { - defer t.gitRepo.Close() + // must stop the repo access before removal, otherwise Windows can't remove the directory occupied by other processes + t.gitRepo.Close() if t.cleanup != nil { t.cleanup() } diff --git a/services/repository/push.go b/services/repository/push.go index 895e3de020..7666ecf377 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -32,10 +32,26 @@ import ( // pushQueue represents a queue to handle update pull request tests var pushQueue *queue.WorkerPoolQueue[[]*repo_module.PushUpdateOptions] -// handle passed PR IDs and test the PRs -func handler(items ...[]*repo_module.PushUpdateOptions) [][]*repo_module.PushUpdateOptions { +func initPushQueue() error { + pushQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "push_update", pushQueueHandler) + if pushQueue == nil { + return errors.New("unable to create push_update queue") + } + go graceful.GetManager().RunWithCancel(pushQueue) + return nil +} + +// PushUpdates adds a push update to push queue, each call must pass the same repo updates +func PushUpdates(opts ...*repo_module.PushUpdateOptions) error { + if len(opts) == 0 { + return nil + } + return pushQueue.Push(opts) +} + +func pushQueueHandler(items ...[]*repo_module.PushUpdateOptions) [][]*repo_module.PushUpdateOptions { for _, opts := range items { - if err := pushUpdates(opts); err != nil { + if err := pushQueueHandleUpdates(opts); err != nil { // Username and repository stays the same between items in opts. pushUpdate := opts[0] log.Error("pushUpdate[%s/%s] failed: %v", pushUpdate.RepoUserName, pushUpdate.RepoName, err) @@ -44,37 +60,8 @@ func handler(items ...[]*repo_module.PushUpdateOptions) [][]*repo_module.PushUpd return nil } -func initPushQueue() error { - pushQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "push_update", handler) - if pushQueue == nil { - return errors.New("unable to create push_update queue") - } - go graceful.GetManager().RunWithCancel(pushQueue) - return nil -} - -// PushUpdate is an alias of PushUpdates for single push update options -func PushUpdate(opts *repo_module.PushUpdateOptions) error { - return PushUpdates([]*repo_module.PushUpdateOptions{opts}) -} - -// PushUpdates adds a push update to push queue -func PushUpdates(opts []*repo_module.PushUpdateOptions) error { - if len(opts) == 0 { - return nil - } - - for _, opt := range opts { - if opt.IsNewRef() && opt.IsDelRef() { - return errors.New("Old and new revisions are both NULL") - } - } - - return pushQueue.Push(opts) -} - -// pushUpdates generates push action history feeds for push updating multiple refs -func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { +// pushQueueHandleUpdates generates push action history feeds for push updating multiple refs +func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error { if len(optsList) == 0 { return nil } @@ -94,7 +81,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { defer gitRepo.Close() if err = repo_module.UpdateRepoSize(ctx, repo); err != nil { - return fmt.Errorf("Failed to update size for repository: %v", err) + return fmt.Errorf("failed to update size for repository: %v", err) } addTags := make([]string, 0, len(optsList)) @@ -104,10 +91,11 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { for _, opts := range optsList { log.Trace("pushUpdates: %-v %s %s %s", repo, opts.OldCommitID, opts.NewCommitID, opts.RefFullName) - if opts.IsNewRef() && opts.IsDelRef() { - return fmt.Errorf("old and new revisions are both %s", objectFormat.EmptyObjectID()) + setting.PanicInDevOrTesting("invalid push update (add+del): %+v", opts) + continue } + if opts.RefFullName.IsTag() { if pusher == nil || pusher.ID != opts.PusherID { if opts.PusherID == user_model.ActionsUserID { @@ -188,11 +176,14 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { return err } - // delete cache for divergence + // sync branch related database data if branch == repo.DefaultBranch { if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil { log.Error("DelRepoDivergenceFromCache: %v", err) } + if err := AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{RepoID: repo.ID}); err != nil { + log.Error("AddRepoToLicenseUpdaterQueue: %v", err) + } } else { if err := DelDivergenceFromCache(repo.ID, branch); err != nil { log.Error("DelDivergenceFromCache: %v", err) diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl index 7d8f2bfc5b..6d1fa21151 100644 --- a/templates/repo/branch/list.tmpl +++ b/templates/repo/branch/list.tmpl @@ -4,7 +4,6 @@
{{template "base/alert" .}} {{template "repo/sub_menu" .}} - {{if .DefaultBranchBranch}}

{{ctx.Locale.Tr "repo.default_branch"}} {{if and $.IsWriter $.Repository.CanContentChange (not .IsDeleted)}} @@ -14,7 +13,8 @@ {{end}}

-
+ {{if .DefaultBranchBranch}} +
@@ -69,6 +69,10 @@
+ {{else}} +
+ {{ctx.Locale.Tr "repo.branch.default_branch_not_exist" $.Repository.DefaultBranch}} +
{{end}}

diff --git a/templates/repo/pulls/status_items.tmpl b/templates/repo/pulls/status_items.tmpl index fa2a5f80bf..f3dc1bb23f 100644 --- a/templates/repo/pulls/status_items.tmpl +++ b/templates/repo/pulls/status_items.tmpl @@ -6,14 +6,14 @@ {{$commitActionsStatuses := ctx.ActionsUtils.CommitStatusesToActionsStatuses $.CommitStatuses}} {{range $cs := $.CommitStatuses}}
-
+
{{$actionStatus := $commitActionsStatuses.IconStatus $cs}} {{if $actionStatus}} {{template "repo/icons/action_status" (dict "Status" $actionStatus "Size" 18 "ClassName" "commit-status icon")}} {{else}} {{template "repo/icons/commit_status" $cs}} {{end}} -
+
{{$cs.Context}} {{$cs.Description}}
@@ -29,9 +29,9 @@ {{end}} {{range $missingCheck := $statusCheckData.MissingRequiredChecks}}
-
+
{{svg "octicon-dot-fill" 16 "commit-status icon tw-text-yellow"}} -
{{$missingCheck}}
+
{{$missingCheck}}
{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index d8e0354acc..707a82a02b 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -2,7 +2,8 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; import {apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; -test('3d model file', async ({page, request}) => { +test('3d model file', async ({page, request, browserName}) => { + test.skip(browserName === 'firefox', 'unclear firefox-only CI-only failure'); // eslint-disable-line playwright/no-skipped-test const repoName = `e2e-3d-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; await apiCreateRepo(request, {name: repoName}); @@ -13,7 +14,7 @@ test('3d model file', async ({page, request}) => { 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(); + await expect(viewer.locator('canvas')).toBeVisible(); // unclear firefox-only CI-only failure 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 @@ -39,19 +40,24 @@ test('pdf file', async ({page, request}) => { }); 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)]); - const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; + const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test-content"]\n'; // on an empty repo, apiCreateFile with newBranch creates that branch as the initial commit - await apiCreateFile(request, owner, repoName, 'readme.cast', cast, {newBranch: 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); + await apiCreateFile(request, owner, repoName, 'test.cast', cast, {newBranch: branch}); + await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}/test.cast`); + const iframe = page.locator('iframe.external-render-iframe'); + const frame = iframe.contentFrame(); + const viewer = frame.locator('#frontend-render-viewer[data-frontend-render-name]'); + await expect(viewer).toHaveAttribute('data-frontend-render-name', 'asciicast'); // render succeeded + await expect(viewer).toHaveAttribute('data-window-origin', 'null'); // no same-origin, avoid XSS + const wrapper = frame.locator('.ap-wrapper'); + await expect(wrapper).toBeVisible(); + await expect(wrapper).toContainText('test-content'); + await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); + await assertNoJsError(page); }); diff --git a/tests/integration/branches_test.go b/tests/integration/branches_test.go index 22b1549252..0763aef68f 100644 --- a/tests/integration/branches_test.go +++ b/tests/integration/branches_test.go @@ -8,6 +8,9 @@ import ( "net/url" "testing" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unittest" "gitea.dev/modules/translation" "gitea.dev/tests" @@ -18,55 +21,52 @@ import ( func TestViewBranches(t *testing.T) { defer tests.PrepareTestEnv(t)() + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) req := NewRequest(t, "GET", "/user2/repo1/branches") resp := MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - _, exists := htmlDoc.doc.Find(".delete-branch-button").Attr("data-url") - assert.False(t, exists, "The template has changed") -} + AssertHTMLElement(t, htmlDoc, "[data-testid=branches-default-branch-list]", 1) + AssertHTMLElement(t, htmlDoc, "[data-testid=branches-default-branch-not-exist]", 0) -func TestDeleteBranch(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - deleteBranch(t) + repo1.DefaultBranch = "non-existent-branch" + _, _ = db.GetEngine(t.Context()).ID(repo1.ID).Cols("default_branch").Update(repo1) + req = NewRequest(t, "GET", "/user2/repo1/branches") + resp = MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + AssertHTMLElement(t, htmlDoc, "[data-testid=branches-default-branch-list]", 0) + AssertHTMLElement(t, htmlDoc, "[data-testid=branches-default-branch-not-exist]", 1) } func TestUndoDeleteBranch(t *testing.T) { + branchAction := func(t *testing.T, button string) (*HTMLDoc, string) { + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/repo1/branches") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find(button).Attr("data-url") + require.True(t, exists, "The template has changed") + linkURL, err := url.Parse(link) + require.NoError(t, err) + + req = NewRequest(t, "POST", link) + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/user2/repo1/branches") + resp = session.MakeRequest(t, req, http.StatusOK) + + return NewHTMLParser(t, resp.Body), linkURL.Query().Get("name") + } + onGiteaRun(t, func(t *testing.T, u *url.URL) { - deleteBranch(t) - htmlDoc, name := branchAction(t, ".restore-branch-button") + htmlDoc, name := branchAction(t, ".delete-branch-button") + assert.Contains(t, + htmlDoc.doc.Find(".ui.positive.message").Text(), + translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name), + ) + htmlDoc, name = branchAction(t, ".restore-branch-button") assert.Contains(t, htmlDoc.doc.Find(".ui.positive.message").Text(), translation.NewLocale("en-US").TrString("repo.branch.restore_success", name), ) }) } - -func deleteBranch(t *testing.T) { - htmlDoc, name := branchAction(t, ".delete-branch-button") - assert.Contains(t, - htmlDoc.doc.Find(".ui.positive.message").Text(), - translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name), - ) -} - -func branchAction(t *testing.T, button string) (*HTMLDoc, string) { - session := loginUser(t, "user2") - req := NewRequest(t, "GET", "/user2/repo1/branches") - resp := session.MakeRequest(t, req, http.StatusOK) - - htmlDoc := NewHTMLParser(t, resp.Body) - link, exists := htmlDoc.doc.Find(button).Attr("data-url") - require.True(t, exists, "The template has changed") - - req = NewRequest(t, "POST", link) - session.MakeRequest(t, req, http.StatusOK) - - url, err := url.Parse(link) - assert.NoError(t, err) - req = NewRequest(t, "GET", "/user2/repo1/branches") - resp = session.MakeRequest(t, req, http.StatusOK) - - return NewHTMLParser(t, resp.Body), url.Query().Get("name") -} diff --git a/tests/integration/compare_test.go b/tests/integration/compare_test.go index 6c00b3fa0e..ac2e014d92 100644 --- a/tests/integration/compare_test.go +++ b/tests/integration/compare_test.go @@ -33,9 +33,17 @@ func TestCompareTag(t *testing.T) { // A dropdown for both base and head. assert.Lenf(t, selection.Nodes, 2, "The template has changed") + req = NewRequest(t, "GET", "/user2/repo1/compare/v1.1...HEAD") + resp = session.MakeRequest(t, req, http.StatusOK) + assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) + + req = NewRequest(t, "GET", "/user2/repo1/compare/v1.1...NotExisting").SetHeader("Accept", "text/html") + resp = session.MakeRequest(t, req, http.StatusNotFound) + assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) + req = NewRequest(t, "GET", "/user2/repo1/compare/invalid").SetHeader("Accept", "text/html") resp = session.MakeRequest(t, req, http.StatusNotFound) - assert.True(t, test.IsNormalPageCompleted(resp.Body.String()), "expect 404 page not 500") + assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) } // Compare with inferred default branch (master) diff --git a/tests/integration/git_smart_http_test.go b/tests/integration/git_smart_http_test.go index dfbda5c701..df8bc1caeb 100644 --- a/tests/integration/git_smart_http_test.go +++ b/tests/integration/git_smart_http_test.go @@ -10,7 +10,9 @@ import ( "testing" auth_model "gitea.dev/models/auth" + "gitea.dev/models/perm" repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" "gitea.dev/models/unittest" "gitea.dev/modules/setting" "gitea.dev/modules/test" @@ -26,6 +28,8 @@ func TestGitSmartHTTP(t *testing.T) { testGitSmartHTTPTokenScopes(t) testRenamedRepoRedirect(t) testGitArchiveRemote(t, u) + t.Run("AnonymousAccess-Repo", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, false) }) + t.Run("AnonymousAccess-Wiki", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, true) }) }) } @@ -144,3 +148,33 @@ func testGitArchiveRemote(t *testing.T, u *url.URL) { t.Run("Fetch HEAD archive subpath", doGitRemoteArchive(u.String(), "HEAD", "test")) t.Run("list compression options", doGitRemoteArchive(u.String(), "--list")) } + +// testGitSmartHTTPPrivateRepoAnonymousAccess tests that a private repo with +// anonymous code access enabled can be cloned without credentials. +func testGitSmartHTTPPrivateRepoAnonymousAccess(t *testing.T, isWiki bool) { + // repo1 (ID=1) belongs to user2 and is public by default in fixtures + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerName: "user2", Name: "repo1"}) + unitType := util.Iif(isWiki, unit.TypeWiki, unit.TypeCode) + repoLink := "/" + repo.FullName() + util.Iif(isWiki, ".wiki", "") + gitPullPath := repoLink + "/info/refs?service=git-upload-pack" + gitPushPath := repoLink + "/info/refs?service=git-receive-pack" + + // make the repo private + require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: repo.ID, IsPrivate: true}, "is_private")) + + // without anonymous access: anonymous pull must require auth + MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized) + + // enable anonymous read access on the unit + require.NoError(t, repo_model.UpdateRepoUnitPublicAccess(t.Context(), &repo_model.RepoUnit{RepoID: repo.ID, Type: unitType, AnonymousAccessMode: perm.AccessModeRead})) + + // with anonymous code access: anonymous pull must succeed without credentials + MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusOK) + + // push (receive-pack) must still require auth even with anonymous code access + MakeRequest(t, NewRequest(t, "GET", gitPushPath), http.StatusUnauthorized) + + // RequireSignInViewStrict must override anonymous access + defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized) +} diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index 9217fd7e5a..41fa56bdc5 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -96,17 +96,17 @@ func TestExternalMarkupRenderer(t *testing.T) { iframe := NewHTMLParser(t, respParent.Body).Find("iframe.external-render-iframe") assert.Empty(t, iframe.AttrOr("src", "")) // src should be empty, "data-src" is used instead - // default sandbox on parent page - assert.Equal(t, "allow-scripts allow-popups", iframe.AttrOr("sandbox", "")) + // no sandbox on parent page because the rendered response should always have correct sandbox + assert.Equal(t, "(non-existing)", iframe.AttrOr("sandbox", "(non-existing)")) assert.Equal(t, "/user2/repo1/render/branch/master/test.html", iframe.AttrOr("data-src", "")) }) - t.Run("SubPage", func(t *testing.T) { + t.Run("FramePage", func(t *testing.T) { req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/test.html") respSub := MakeRequest(t, req, http.StatusOK) assert.Equal(t, "text/html; charset=utf-8", respSub.Header().Get("Content-Type")) - // default sandbox in sub page response - assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) + // default sandbox in sub-page response (there should be no "allow-same-origin") + assert.Equal(t, "sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "`+ @@ -127,10 +127,7 @@ func TestExternalMarkupRenderer(t *testing.T) { req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/bin.no-sanitizer") respSub := MakeRequest(t, req, http.StatusOK) assert.Equal(t, binaryContent, respSub.Body.String()) // raw content should keep the raw bytes (including invalid UTF-8 bytes), and no "external-render-iframe" helpers - - // no sandbox (disabled by RENDER_CONTENT_SANDBOX) - assert.Empty(t, iframe.AttrOr("sandbox", "")) - assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) + assert.Empty(t, respSub.Header().Get("Content-Security-Policy"), "sandbox is disabled by RENDER_CONTENT_SANDBOX") }) t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { @@ -142,7 +139,7 @@ func TestExternalMarkupRenderer(t *testing.T) { ``, respSub.Body.String(), ) - assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) + assert.Empty(t, respSub.Header().Get("Content-Security-Policy")) }) }) }) diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index a12735e06d..95a1df283f 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -5,7 +5,6 @@ RUN_MODE = prod [database] DB_TYPE = sqlite3 PATH = gitea-test.db -SQLITE_JOURNAL_MODE = WAL [indexer] REPO_INDEXER_ENABLED = true diff --git a/types.d.ts b/types.d.ts index bdf35428bc..d6325f5cbd 100644 --- a/types.d.ts +++ b/types.d.ts @@ -50,7 +50,7 @@ declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { declare module 'asciinema-player' { interface AsciinemaPlayer { - create(src: string, element: HTMLElement, options?: Record): void; + create(src: string | {data: string}, element: HTMLElement, options?: Record): void; } const exports: AsciinemaPlayer; export = exports; diff --git a/web_src/css/base.css b/web_src/css/base.css index 4b489362af..92b82e9d89 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -840,7 +840,8 @@ table th[data-sortt-desc] .svg { align-items: center; gap: var(--gap-inline); vertical-align: middle; - min-width: 0; /* make ellipsis work */ + max-width: 100%; /* the inner part might have "gt-ellipsis" */ + min-width: 0; /* if it is the top flex container, "max-width" works; but if it is inside another flex container, the parent needs to handle the x-axis and here also needs "min-width" */ } .ui.ui.labeled.button { @@ -856,6 +857,7 @@ table th[data-sortt-desc] .svg { justify-content: space-between; align-items: center; gap: var(--gap-block); + max-width: 100%; min-width: 0; } @@ -868,6 +870,7 @@ table th[data-sortt-desc] .svg { align-items: center; gap: var(--gap-block); max-width: 100%; + min-width: 0; } .flex-left-right > .ui.button, diff --git a/web_src/css/index.css b/web_src/css/index.css index 6d9280c67f..2d3e118825 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -52,7 +52,6 @@ @import "./markup/content.css"; @import "./markup/codeblock.css"; @import "./markup/codepreview.css"; -@import "./markup/asciicast.css"; @import "./font_i18n.css"; @import "./base.css"; diff --git a/web_src/css/markup/asciicast.css b/web_src/css/markup/asciicast.css deleted file mode 100644 index a45daaa8e8..0000000000 --- a/web_src/css/markup/asciicast.css +++ /dev/null @@ -1,10 +0,0 @@ -.asciinema-player-container { - width: 100%; - height: auto; -} - -/* Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js :
-Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159 */ -.ap-term { - overflow: hidden !important; -} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 79d1b12692..d433cd6fb4 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -210,10 +210,6 @@ td .commit-summary { overflow: auto; } -.non-diff-file-content .asciicast { - padding: 0 !important; -} - .repo-editor-header { /* it should match ".repo-button-row" so the tree toggle button stays aligned */ margin: 8px 0; @@ -1867,11 +1863,11 @@ tbody.commit-list { } .username-display { - max-width: 100%; /* the inner part might have "gt-ellipsis" */ - min-width: 0; /* if it is the top flex container, "max-width" works; but if it is inside another flex container, the parent needs to handle the x-axis and here also needs "min-width" */ display: inline-flex; gap: var(--gap-inline); align-items: center; + max-width: 100%; /* min/max widths are for "gt-ellipsis", see the comment of other "flex-xxx" family classes */ + min-width: 0; } .username-display > .username-fullname { diff --git a/web_src/fomantic/build/components/dropdown.js b/web_src/fomantic/build/components/dropdown.js index 0faed1858c..f971fecda4 100644 --- a/web_src/fomantic/build/components/dropdown.js +++ b/web_src/fomantic/build/components/dropdown.js @@ -1953,8 +1953,8 @@ $.fn.dropdown = function(parameters) { $choice.find(selector.menu).remove(); $choice.find(selector.menuIcon).remove(); } - return ($choice.data(metadata.text) !== undefined) - ? $choice.data(metadata.text) + return ($choice.attr('data-' + metadata.text) !== undefined) // GITEA-PATCH: use "attr" but not "data", don't decode JSON like "false" + ? $choice.attr('data-' + metadata.text) : (preserveHTML) ? $choice.html().trim() : $choice.text().trim() @@ -2007,8 +2007,8 @@ $.fn.dropdown = function(parameters) { value = ( $option.attr('value') !== undefined ) ? $option.attr('value') : name, - text = ( $option.data(metadata.text) !== undefined ) - ? $option.data(metadata.text) + text = ( $option.attr('data-' + metadata.text) !== undefined ) // GITEA-PATCH: use "attr" but not "data", don't decode JSON like "false" + ? $option.attr('data-' + metadata.text) : name, group = $option.parent('optgroup') ; diff --git a/web_src/js/external-render-frontend.ts b/web_src/js/external-render-frontend.ts index 9d969bcf90..e7e3f4f1be 100644 --- a/web_src/js/external-render-frontend.ts +++ b/web_src/js/external-render-frontend.ts @@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>; const frontendPlugins: Record = { 'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'), 'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'), + 'asciicast': () => import('./render/plugins/frontend-asciicast.ts'), }; class Options implements FrontendRenderOptions { @@ -44,23 +45,28 @@ 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')!; + viewerContainer.setAttribute('data-window-origin', window.origin); // mainly for testing purpose 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; + let renderName = '', rendered = false; for (const name of renderNames) { if (!(name in frontendPlugins)) continue; const plugin = await frontendPlugins[name](); - found = true; - if (await plugin.frontendRender(opts)) break; + renderName = name; + rendered = await plugin.frontendRender(opts); + if (rendered) break; } - if (!found) { + if (!renderName) { viewerContainer.textContent = 'No frontend render plugin found for this file, but backend declares that there must be one, there must be a bug'; + } else if (!rendered) { + viewerContainer.textContent = `Failed to render by ${renderName}`; + } else { + viewerContainer.setAttribute('data-frontend-render-name', renderName); // succeeded render, mainly for testing purpose } } diff --git a/web_src/js/external-render-helper.test.ts b/web_src/js/external-render-helper.test.ts index 452d7f8f2d..3bb524a011 100644 --- a/web_src/js/external-render-helper.test.ts +++ b/web_src/js/external-render-helper.test.ts @@ -1,7 +1,7 @@ import './external-render-helper.ts'; test('isValidCssColor', async () => { - const isValidCssColor = window.testModules.externalRenderHelper!.isValidCssColor; + const isValidCssColor = window.giteaExternalRenderHelper!.isValidCssColor; expect(isValidCssColor(null)).toBe(false); expect(isValidCssColor('')).toBe(false); diff --git a/web_src/js/external-render-helper.ts b/web_src/js/external-render-helper.ts index f92aeb9c6c..8a5122fe99 100644 --- a/web_src/js/external-render-helper.ts +++ b/web_src/js/external-render-helper.ts @@ -50,12 +50,12 @@ body { background: ${backgroundColor}; } } 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 = {}) => { - window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*'); - }; +// iframe is in different origin, so we need to use postMessage to communicate +const postIframeMsg = (cmd: string, data: Record = {}) => { + window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*'); +}; +if (iframeId) { const updateIframeHeight = () => { if (!document.body) return; // the body might not be available when this function is called // Use scrollHeight to get the full content height, even when CSS sets html/body to height:100% @@ -90,6 +90,4 @@ if (iframeId) { }); } -if (window.testModules) { - window.testModules.externalRenderHelper = {isValidCssColor}; -} +window.giteaExternalRenderHelper = {isValidCssColor, queryParams, postIframeMsg}; diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index 5398d407d1..6579bb2203 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -68,13 +68,13 @@ interface Window { turnstile: any, hcaptcha: any, - // Make IIFE private functions can be tested in unit tests, without exposing the IIFE module to global scope. + // Make IIFE private functions can be managed by us in our scope, without exposing the IIFE module to global scope. // Otherwise, when using "export" in IIFE code, the compiled JS will inject global "var externalRenderHelper = ..." // which is not expected and may cause conflicts with other modules. - testModules: { - externalRenderHelper?: { - isValidCssColor(s: string | null): boolean, - } + giteaExternalRenderHelper?: { + isValidCssColor(s: string | null): boolean, + queryParams: URLSearchParams, + postIframeMsg(cmd: string, data: Record = {}), } // do not add more properties here unless it is a must diff --git a/web_src/js/markup/asciicast.ts b/web_src/js/markup/asciicast.ts deleted file mode 100644 index 90515e1363..0000000000 --- a/web_src/js/markup/asciicast.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {queryElems} from '../utils/dom.ts'; - -export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise { - queryElems(elMarkup, '.asciinema-player-container', async (el) => { - const [player] = await Promise.all([ - import('asciinema-player'), - import('asciinema-player/dist/bundle/asciinema-player.css'), - ]); - - player.create(el.getAttribute('data-asciinema-player-src')!, el, { - // poster (a preview frame) to display until the playback is started. - // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. - poster: 'npt:1:0:0', - }); - }); -} diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts index 44c005a7c8..09dee98754 100644 --- a/web_src/js/markup/content.ts +++ b/web_src/js/markup/content.ts @@ -1,7 +1,6 @@ import {initMarkupCodeMermaid} from './mermaid.ts'; import {initMarkupCodeMath} from './math.ts'; import {initMarkupCodeCopy} from './codecopy.ts'; -import {initMarkupRenderAsciicast} from './asciicast.ts'; import {initMarkupTasklist} from './tasklist.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; import {initExternalRenderIframe} from './render-iframe.ts'; @@ -24,6 +23,5 @@ export function initMarkupContent(): void { initMarkupTasklist(el); initMarkupCodeMermaid(el); initMarkupCodeMath(el); - initMarkupRenderAsciicast(el); }); } diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index f05523943a..6a191ac5c6 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -1,7 +1,6 @@ import {generateElemId} from '../utils/dom.ts'; import {errorMessage} from '../modules/errors.ts'; import {isDarkTheme} from '../utils.ts'; -import {GET} from '../modules/fetch.ts'; function safeRenderIframeLink(link: any): string | null { try { @@ -65,9 +64,31 @@ export async function initExternalRenderIframe(iframe: HTMLIFrameElement) { u.searchParams.set('gitea-iframe-id', iframe.id); u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe)); - // 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(); + // There are 3 kinds of external render modes: + // * external frontend render: + // * parent page creates iframe, iframe navigates to render page + // * render generates frame page with external-render-helper (injected), external-render-frontend and file content (hidden textarea) + // * frame page executes external-render-frontend JS code to finds a frontend plugin to render + // * external backend render (HTML) + // * parent page creates iframe, iframe navigates to render page + // * render executes command to generate rendered HTML content with external-render-helper (injected) + // * frame page displays the rendered content + // * external backend render (non-HTML, e.g.: PDF, image) + // * parent page creates iframe, iframe navigates to render page + // * render executes command to generate rendered content + // * response header is automatically detected from rendered content + + // It must use "src" here, because the frame content should not inherit parent's CSP. + // Otherwise, "srcdoc" makes the frame content inherit the parent's CSP, + // then some renders like "asciicast (asciinema)" which require "unsafe-eval" won't work. + // + // When using "src", Chrome can report false-alarm error like: + // * Unsafe attempt to load URL http://localhost/owner/repo/render/branch/main/file from frame with URL http://localhost/owner/repo/render/branch/main/file. Domains, protocols and ports must match. + // (only for the first time that the developer opens the browser console) + // Such error log can also appear even if you access the link "http://.../owner/repo/render/branch/main/file" directly. + // Everything just works, it is just a false-alarm caused by Chrome's Developer Tools, so such error log can be ignored. + // + // Another reason for why "src" is a must: if the render outputs non-HTML contents like PDF or image, + // Only "src" can correctly load and display the rendered content, "srcdoc" won't work. + iframe.src = u.href; } diff --git a/web_src/js/modules/fomantic/dropdown.test.ts b/web_src/js/modules/fomantic/dropdown.test.ts index 542cb854b8..4ea51b3d2c 100644 --- a/web_src/js/modules/fomantic/dropdown.test.ts +++ b/web_src/js/modules/fomantic/dropdown.test.ts @@ -1,6 +1,23 @@ +import '../../../fomantic/build/fomantic.js'; import {createElementFromHTML} from '../../utils/dom.ts'; import {hideScopedEmptyDividers} from './dropdown.ts'; +test('dropdown-item-literal-text', () => { + // a "choice" workflow_dispatch input can offer the string "false" as an option. + // jQuery `.data()` would coerce `data-text="false"` to the boolean `false`, which then renders as empty text. + const $dropdown = $(``).dropdown(); + for (const value of ['1', '0', 'true', 'false']) { + $dropdown.dropdown('set selected', value); + expect($dropdown.dropdown('get text')).toEqual(value); + expect($dropdown.dropdown('get value')).toEqual(value); + } +}); + test('hideScopedEmptyDividers-simple', () => { const container = createElementFromHTML(`
diff --git a/web_src/js/render/plugins/frontend-asciicast.ts b/web_src/js/render/plugins/frontend-asciicast.ts new file mode 100644 index 0000000000..f8bdefb316 --- /dev/null +++ b/web_src/js/render/plugins/frontend-asciicast.ts @@ -0,0 +1,23 @@ +import type {FrontendRenderFunc} from '../plugin.ts'; + +export const frontendRender: FrontendRenderFunc = async (opts): Promise => { + try { + const [player] = await Promise.all([ + import('asciinema-player'), + import('asciinema-player/dist/bundle/asciinema-player.css'), + ]); + player.create({data: opts.contentString()}, opts.container, { + // poster (a preview frame) to display until the playback is started. + // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. + poster: 'npt:1:0:0', + }); + // Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js :
+ // Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159 + opts.container.querySelector('.ap-term')!.style.overflow = 'hidden'; + opts.container.querySelector('.ap-player')!.style.borderRadius = '0'; + return true; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/web_src/js/vitest.setup.ts b/web_src/js/vitest.setup.ts index a6ec019ff8..1c0c27a667 100644 --- a/web_src/js/vitest.setup.ts +++ b/web_src/js/vitest.setup.ts @@ -14,5 +14,3 @@ window.config = { i18n: {}, frontendInited: false, }; - -window.testModules = {};