diff --git a/flake.nix b/flake.nix index 2c9d74c137ecb..e3655b627ebe5 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,6 @@ poetry # backend - go_1_22 gofumpt sqlite ]; diff --git a/models/db/collation.go b/models/db/collation.go index c128cf502955e..a7db9f54423b9 100644 --- a/models/db/collation.go +++ b/models/db/collation.go @@ -68,7 +68,8 @@ func CheckCollations(x *xorm.Engine) (*CheckCollationsResult, error) { var candidateCollations []string if x.Dialect().URI().DBType == schemas.MYSQL { - if _, err = x.SQL("SELECT @@collation_database").Get(&res.DatabaseCollation); err != nil { + _, err = x.SQL("SELECT DEFAULT_COLLATION_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?", setting.Database.Name).Get(&res.DatabaseCollation) + if err != nil { return nil, err } res.IsCollationCaseSensitive = func(s string) bool { diff --git a/models/repo/fork.go b/models/repo/fork.go index 07cd31c2690a9..1c75e86458b2f 100644 --- a/models/repo/fork.go +++ b/models/repo/fork.go @@ -54,21 +54,6 @@ func GetUserFork(ctx context.Context, repoID, userID int64) (*Repository, error) return &forkedRepo, nil } -// GetForks returns all the forks of the repository -func GetForks(ctx context.Context, repo *Repository, listOptions db.ListOptions) ([]*Repository, error) { - sess := db.GetEngine(ctx) - - var forks []*Repository - if listOptions.Page == 0 { - forks = make([]*Repository, 0, repo.NumForks) - } else { - forks = make([]*Repository, 0, listOptions.PageSize) - sess = db.SetSessionPagination(sess, &listOptions) - } - - return forks, sess.Find(&forks, &Repository{ForkID: repo.ID}) -} - // IncrementRepoForkNum increment repository fork number func IncrementRepoForkNum(ctx context.Context, repoID int64) error { _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_forks=num_forks+1 WHERE id=?", repoID) diff --git a/models/repo/pushmirror.go b/models/repo/pushmirror.go index bf134abfb152a..55e8f3a068f28 100644 --- a/models/repo/pushmirror.go +++ b/models/repo/pushmirror.go @@ -9,15 +9,13 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) -// ErrPushMirrorNotExist mirror does not exist error -var ErrPushMirrorNotExist = util.NewNotExistErrorf("PushMirror does not exist") - // PushMirror represents mirror information of a repository. type PushMirror struct { ID int64 `xorm:"pk autoincr"` @@ -96,26 +94,46 @@ func DeletePushMirrors(ctx context.Context, opts PushMirrorOptions) error { return util.NewInvalidArgumentErrorf("repoID required and must be set") } +type findPushMirrorOptions struct { + db.ListOptions + RepoID int64 + SyncOnCommit optional.Option[bool] +} + +func (opts findPushMirrorOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } + if opts.SyncOnCommit.Has() { + cond = cond.And(builder.Eq{"sync_on_commit": opts.SyncOnCommit.Value()}) + } + return cond +} + // GetPushMirrorsByRepoID returns push-mirror information of a repository. func GetPushMirrorsByRepoID(ctx context.Context, repoID int64, listOptions db.ListOptions) ([]*PushMirror, int64, error) { - sess := db.GetEngine(ctx).Where("repo_id = ?", repoID) - if listOptions.Page != 0 { - sess = db.SetSessionPagination(sess, &listOptions) - mirrors := make([]*PushMirror, 0, listOptions.PageSize) - count, err := sess.FindAndCount(&mirrors) - return mirrors, count, err + return db.FindAndCount[PushMirror](ctx, findPushMirrorOptions{ + ListOptions: listOptions, + RepoID: repoID, + }) +} + +func GetPushMirrorByIDAndRepoID(ctx context.Context, id, repoID int64) (*PushMirror, bool, error) { + var pushMirror PushMirror + has, err := db.GetEngine(ctx).Where("id = ?", id).And("repo_id = ?", repoID).Get(&pushMirror) + if !has || err != nil { + return nil, has, err } - mirrors := make([]*PushMirror, 0, 10) - count, err := sess.FindAndCount(&mirrors) - return mirrors, count, err + return &pushMirror, true, nil } // GetPushMirrorsSyncedOnCommit returns push-mirrors for this repo that should be updated by new commits func GetPushMirrorsSyncedOnCommit(ctx context.Context, repoID int64) ([]*PushMirror, error) { - mirrors := make([]*PushMirror, 0, 10) - return mirrors, db.GetEngine(ctx). - Where("repo_id = ? AND sync_on_commit = ?", repoID, true). - Find(&mirrors) + return db.Find[PushMirror](ctx, findPushMirrorOptions{ + RepoID: repoID, + SyncOnCommit: optional.Some(true), + }) } // PushMirrorsIterate iterates all push-mirror repositories. diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index 1bffadbf0ae42..9bed2e919723b 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -98,8 +98,7 @@ func (repos RepositoryList) IDs() []int64 { return repoIDs } -// LoadAttributes loads the attributes for the given RepositoryList -func (repos RepositoryList) LoadAttributes(ctx context.Context) error { +func (repos RepositoryList) LoadOwners(ctx context.Context) error { if len(repos) == 0 { return nil } @@ -107,10 +106,6 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error { userIDs := container.FilterSlice(repos, func(repo *Repository) (int64, bool) { return repo.OwnerID, true }) - repoIDs := make([]int64, len(repos)) - for i := range repos { - repoIDs[i] = repos[i].ID - } // Load owners. users := make(map[int64]*user_model.User, len(userIDs)) @@ -123,12 +118,19 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error { for i := range repos { repos[i].Owner = users[repos[i].OwnerID] } + return nil +} + +func (repos RepositoryList) LoadLanguageStats(ctx context.Context) error { + if len(repos) == 0 { + return nil + } // Load primary language. stats := make(LanguageStatList, 0, len(repos)) if err := db.GetEngine(ctx). Where("`is_primary` = ? AND `language` != ?", true, "other"). - In("`repo_id`", repoIDs). + In("`repo_id`", repos.IDs()). Find(&stats); err != nil { return fmt.Errorf("find primary languages: %w", err) } @@ -141,10 +143,18 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error { } } } - return nil } +// LoadAttributes loads the attributes for the given RepositoryList +func (repos RepositoryList) LoadAttributes(ctx context.Context) error { + if err := repos.LoadOwners(ctx); err != nil { + return err + } + + return repos.LoadLanguageStats(ctx) +} + // SearchRepoOptions holds the search options type SearchRepoOptions struct { db.ListOptions diff --git a/modules/html/html.go b/modules/html/html.go deleted file mode 100644 index b1ebd584c6b6d..0000000000000 --- a/modules/html/html.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package html - -// ParseSizeAndClass get size and class from string with default values -// If present, "others" expects the new size first and then the classes to use -func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int, string) { - size := defaultSize - if len(others) >= 1 { - if v, ok := others[0].(int); ok && v != 0 { - size = v - } - } - class := defaultClass - if len(others) >= 2 { - if v, ok := others[1].(string); ok && v != "" { - if class != "" { - class += " " - } - class += v - } - } - return size, class -} diff --git a/modules/htmlutil/html.go b/modules/htmlutil/html.go new file mode 100644 index 0000000000000..9b5f5a92d89a8 --- /dev/null +++ b/modules/htmlutil/html.go @@ -0,0 +1,48 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package htmlutil + +import ( + "fmt" + "html/template" + "slices" +) + +// ParseSizeAndClass get size and class from string with default values +// If present, "others" expects the new size first and then the classes to use +func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int, string) { + size := defaultSize + if len(others) >= 1 { + if v, ok := others[0].(int); ok && v != 0 { + size = v + } + } + class := defaultClass + if len(others) >= 2 { + if v, ok := others[1].(string); ok && v != "" { + if class != "" { + class += " " + } + class += v + } + } + return size, class +} + +func HTMLFormat(s string, rawArgs ...any) template.HTML { + args := slices.Clone(rawArgs) + for i, v := range args { + switch v := v.(type) { + case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML: + // for most basic types (including template.HTML which is safe), just do nothing and use it + case string: + args[i] = template.HTMLEscapeString(v) + case fmt.Stringer: + args[i] = template.HTMLEscapeString(v.String()) + default: + args[i] = template.HTMLEscapeString(fmt.Sprint(v)) + } + } + return template.HTML(fmt.Sprintf(s, args...)) +} diff --git a/modules/htmlutil/html_test.go b/modules/htmlutil/html_test.go new file mode 100644 index 0000000000000..5ff05d75b36cc --- /dev/null +++ b/modules/htmlutil/html_test.go @@ -0,0 +1,15 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package htmlutil + +import ( + "html/template" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHTMLFormat(t *testing.T) { + assert.Equal(t, template.HTML("< < 1"), HTMLFormat("%s %s %d", "<", template.HTML("<"), 1)) +} diff --git a/modules/markup/asciicast/asciicast.go b/modules/markup/asciicast/asciicast.go index 06780623403a4..e92b78a4bcad6 100644 --- a/modules/markup/asciicast/asciicast.go +++ b/modules/markup/asciicast/asciicast.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/url" - "regexp" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" @@ -38,10 +37,7 @@ const ( // SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { - return []setting.MarkupSanitizerRule{ - {Element: "div", AllowAttr: "class", Regexp: regexp.MustCompile(playerClassName)}, - {Element: "div", AllowAttr: playerSrcAttr}, - } + return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}} } // Render implements markup.Renderer @@ -53,12 +49,5 @@ func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) ctx.Metas["BranchNameSubURL"], url.PathEscape(ctx.RelativePath), ) - - _, err := io.WriteString(output, fmt.Sprintf( - `
`, - playerClassName, - playerSrcAttr, - rawURL, - )) - return err + return ctx.RenderInternal.FormatWithSafeAttrs(output, ``, playerClassName, playerSrcAttr, rawURL) } diff --git a/modules/markup/common/html.go b/modules/markup/common/html.go deleted file mode 100644 index 5658839c6fc37..0000000000000 --- a/modules/markup/common/html.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package common - -import ( - "mvdan.cc/xurls/v2" -) - -// NOTE: All below regex matching do not perform any extra validation. -// Thus a link is produced even if the linked entity does not exist. -// While fast, this is also incorrect and lead to false positives. -// TODO: fix invalid linking issue - -// LinkRegex is a regexp matching a valid link -var LinkRegex, _ = xurls.StrictMatchingScheme("https?://") diff --git a/modules/markup/common/linkify.go b/modules/markup/common/linkify.go index f84680205e888..be6ab22b55f49 100644 --- a/modules/markup/common/linkify.go +++ b/modules/markup/common/linkify.go @@ -9,15 +9,27 @@ package common import ( "bytes" "regexp" + "sync" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" + "mvdan.cc/xurls/v2" ) -var wwwURLRegxp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`) +type GlobalVarsType struct { + wwwURLRegxp *regexp.Regexp + LinkRegex *regexp.Regexp // fast matching a URL link, no any extra validation. +} + +var GlobalVars = sync.OnceValue[*GlobalVarsType](func() *GlobalVarsType { + v := &GlobalVarsType{} + v.wwwURLRegxp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`) + v.LinkRegex, _ = xurls.StrictMatchingScheme("https?://") + return v +}) type linkifyParser struct{} @@ -60,10 +72,10 @@ func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont var protocol []byte typ := ast.AutoLinkURL if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) { - m = LinkRegex.FindSubmatchIndex(line) + m = GlobalVars().LinkRegex.FindSubmatchIndex(line) } if m == nil && bytes.HasPrefix(line, domainWWW) { - m = wwwURLRegxp.FindSubmatchIndex(line) + m = GlobalVars().wwwURLRegxp.FindSubmatchIndex(line) protocol = []byte("http") } if m != nil { diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go index d991527b80f59..06f3acfa68948 100644 --- a/modules/markup/console/console.go +++ b/modules/markup/console/console.go @@ -6,8 +6,7 @@ package console import ( "bytes" "io" - "path/filepath" - "regexp" + "path" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" @@ -36,7 +35,7 @@ func (Renderer) Extensions() []string { // SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{ - {Element: "span", AllowAttr: "class", Regexp: regexp.MustCompile(`^term-((fg[ix]?|bg)\d+|container)$`)}, + {Element: "span", AllowAttr: "class", Regexp: `^term-((fg[ix]?|bg)\d+|container)$`}, } } @@ -46,7 +45,7 @@ func (Renderer) CanRender(filename string, input io.Reader) bool { if err != nil { return false } - if enry.GetLanguage(filepath.Base(filename), buf) != enry.OtherLanguage { + if enry.GetLanguage(path.Base(filename), buf) != enry.OtherLanguage { return false } return bytes.ContainsRune(buf, '\x1b') diff --git a/modules/markup/csv/csv.go b/modules/markup/csv/csv.go index 3d952b0de4fe3..a3e6bbaac6830 100644 --- a/modules/markup/csv/csv.go +++ b/modules/markup/csv/csv.go @@ -7,7 +7,6 @@ import ( "bufio" "html" "io" - "regexp" "strconv" "code.gitea.io/gitea/modules/csv" @@ -37,9 +36,9 @@ func (Renderer) Extensions() []string { // SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{ - {Element: "table", AllowAttr: "class", Regexp: regexp.MustCompile(`data-table`)}, - {Element: "th", AllowAttr: "class", Regexp: regexp.MustCompile(`line-num`)}, - {Element: "td", AllowAttr: "class", Regexp: regexp.MustCompile(`line-num`)}, + {Element: "table", AllowAttr: "class", Regexp: `^data-table$`}, + {Element: "th", AllowAttr: "class", Regexp: `^line-num$`}, + {Element: "td", AllowAttr: "class", Regexp: `^line-num$`}, } } @@ -51,13 +50,13 @@ func writeField(w io.Writer, element, class, field string) error { return err } if len(class) > 0 { - if _, err := io.WriteString(w, " class=\""); err != nil { + if _, err := io.WriteString(w, ` class="`); err != nil { return err } if _, err := io.WriteString(w, class); err != nil { return err } - if _, err := io.WriteString(w, "\""); err != nil { + if _, err := io.WriteString(w, `"`); err != nil { return err } } diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 122517ed11c0a..d28dc9fa5d19a 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -102,7 +102,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io. _, err = io.Copy(f, input) if err != nil { - f.Close() + _ = f.Close() return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err) } @@ -113,10 +113,9 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io. args = append(args, f.Name()) } - if ctx == nil || ctx.Ctx == nil { - if ctx == nil { - log.Warn("RenderContext not provided defaulting to empty ctx") - ctx = &markup.RenderContext{} + if ctx.Ctx == nil { + if !setting.IsProd || setting.IsInTesting { + panic("RenderContext did not provide context") } log.Warn("RenderContext did not provide context, defaulting to Shutdown context") ctx.Ctx = graceful.GetManager().ShutdownContext() diff --git a/modules/markup/html.go b/modules/markup/html.go index 16ccd4b40672f..e8799c401c537 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -25,9 +25,6 @@ const ( IssueNameStyleRegexp = "regexp" ) -// CSS class for action keywords (e.g. "closes: #1") -const keywordClass = "issue-keyword" - type globalVarsType struct { hashCurrentPattern *regexp.Regexp shortLinkPattern *regexp.Regexp @@ -39,6 +36,7 @@ type globalVarsType struct { emojiShortCodeRegex *regexp.Regexp issueFullPattern *regexp.Regexp filesChangedFullPattern *regexp.Regexp + codePreviewPattern *regexp.Regexp tagCleaner *regexp.Regexp nulCleaner *strings.Replacer @@ -88,6 +86,9 @@ var globalVars = sync.OnceValue[*globalVarsType](func() *globalVarsType { // example: https://domain/org/repo/pulls/27/files#hash v.filesChangedFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`) + // codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20" + v.codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`) + v.tagCleaner = regexp.MustCompile(`<((?:/?\w+/\w+)|(?:/[\w ]+/)|(/?[hH][tT][mM][lL]\b)|(/?[hH][eE][aA][dD]\b))`) v.nulCleaner = strings.NewReplacer("\000", "") return v @@ -129,7 +130,7 @@ func CustomLinkURLSchemes(schemes []string) { } withAuth = append(withAuth, s) } - common.LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) + common.GlobalVars().LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) } type postProcessError struct { @@ -164,11 +165,7 @@ var defaultProcessors = []processor{ // emails with HTML links, parsing shortlinks in the format of [[Link]], like // MediaWiki, linking issues in the format #ID, and mentions in the format // @user, and others. -func PostProcess( - ctx *RenderContext, - input io.Reader, - output io.Writer, -) error { +func PostProcess(ctx *RenderContext, input io.Reader, output io.Writer) error { return postProcess(ctx, defaultProcessors, input, output) } @@ -189,10 +186,7 @@ var commitMessageProcessors = []processor{ // RenderCommitMessage will use the same logic as PostProcess, but will disable // the shortLinkProcessor and will add a defaultLinkProcessor if defaultLink is // set, which changes every text node into a link to the passed default link. -func RenderCommitMessage( - ctx *RenderContext, - content string, -) (string, error) { +func RenderCommitMessage(ctx *RenderContext, content string) (string, error) { procs := commitMessageProcessors return renderProcessString(ctx, procs, content) } @@ -219,10 +213,7 @@ var emojiProcessors = []processor{ // RenderCommitMessage, but will disable the shortLinkProcessor and // emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set, // which changes every text node into a link to the passed default link. -func RenderCommitMessageSubject( - ctx *RenderContext, - defaultLink, content string, -) (string, error) { +func RenderCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) { procs := slices.Clone(commitMessageSubjectProcessors) procs = append(procs, func(ctx *RenderContext, node *html.Node) { ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data} @@ -236,10 +227,7 @@ func RenderCommitMessageSubject( } // RenderIssueTitle to process title on individual issue/pull page -func RenderIssueTitle( - ctx *RenderContext, - title string, -) (string, error) { +func RenderIssueTitle(ctx *RenderContext, title string) (string, error) { // do not render other issue/commit links in an issue's title - which in most cases is already a link. return renderProcessString(ctx, []processor{ emojiShortCodeProcessor, @@ -257,10 +245,7 @@ func renderProcessString(ctx *RenderContext, procs []processor, content string) // RenderDescriptionHTML will use similar logic as PostProcess, but will // use a single special linkProcessor. -func RenderDescriptionHTML( - ctx *RenderContext, - content string, -) (string, error) { +func RenderDescriptionHTML(ctx *RenderContext, content string) (string, error) { return renderProcessString(ctx, []processor{ descriptionLinkProcessor, emojiShortCodeProcessor, @@ -270,10 +255,7 @@ func RenderDescriptionHTML( // RenderEmoji for when we want to just process emoji and shortcodes // in various places it isn't already run through the normal markdown processor -func RenderEmoji( - ctx *RenderContext, - content string, -) (string, error) { +func RenderEmoji(ctx *RenderContext, content string) (string, error) { return renderProcessString(ctx, emojiProcessors, content) } @@ -333,6 +315,17 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output return nil } +func isEmojiNode(node *html.Node) bool { + if node.Type == html.ElementNode && node.Data == atom.Span.String() { + for _, attr := range node.Attr { + if (attr.Key == "class" || attr.Key == "data-attr-class") && strings.Contains(attr.Val, "emoji") { + return true + } + } + } + return false +} + func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Node { // Add user-content- to IDs and "#" links if they don't already have them for idx, attr := range node.Attr { @@ -346,47 +339,27 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod if attr.Key == "href" && strings.HasPrefix(attr.Val, "#") && notHasPrefix { node.Attr[idx].Val = "#user-content-" + val } - - if attr.Key == "class" && attr.Val == "emoji" { - procs = nil - } } switch node.Type { case html.TextNode: - processTextNodes(ctx, procs, node) + for _, proc := range procs { + proc(ctx, node) // it might add siblings + } + case html.ElementNode: - if node.Data == "code" || node.Data == "pre" { - // ignore code and pre nodes + if isEmojiNode(node) { + // TextNode emoji will be converted to ``, then the next iteration will visit the "span" + // if we don't stop it, it will go into the TextNode again and create an infinite recursion return node.NextSibling + } else if node.Data == "code" || node.Data == "pre" { + return node.NextSibling // ignore code and pre nodes } else if node.Data == "img" { return visitNodeImg(ctx, node) } else if node.Data == "video" { return visitNodeVideo(ctx, node) } else if node.Data == "a" { - // Restrict text in links to emojis - procs = emojiProcessors - } else if node.Data == "i" { - for _, attr := range node.Attr { - if attr.Key != "class" { - continue - } - classes := strings.Split(attr.Val, " ") - for i, class := range classes { - if class == "icon" { - classes[0], classes[i] = classes[i], classes[0] - attr.Val = strings.Join(classes, " ") - - // Remove all children of icons - child := node.FirstChild - for child != nil { - node.RemoveChild(child) - child = node.FirstChild - } - break - } - } - } + procs = emojiProcessors // Restrict text in links to emojis } for n := node.FirstChild; n != nil; { n = visitNode(ctx, procs, n) @@ -396,22 +369,17 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod return node.NextSibling } -// processTextNodes runs the passed node through various processors, in order to handle -// all kinds of special links handled by the post-processing. -func processTextNodes(ctx *RenderContext, procs []processor, node *html.Node) { - for _, p := range procs { - p(ctx, node) - } -} - // createKeyword() renders a highlighted version of an action keyword -func createKeyword(content string) *html.Node { +func createKeyword(ctx *RenderContext, content string) *html.Node { + // CSS class for action keywords (e.g. "closes: #1") + const keywordClass = "issue-keyword" + span := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{}, } - span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: keywordClass}) + span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", keywordClass)) text := &html.Node{ Type: html.TextNode, @@ -422,7 +390,7 @@ func createKeyword(content string) *html.Node { return span } -func createLink(href, content, class string) *html.Node { +func createLink(ctx *RenderContext, href, content, class string) *html.Node { a := &html.Node{ Type: html.ElementNode, Data: atom.A.String(), @@ -432,7 +400,7 @@ func createLink(href, content, class string) *html.Node { a.Attr = append(a.Attr, html.Attribute{Key: "data-markdown-generated-content"}) } if class != "" { - a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class}) + a.Attr = append(a.Attr, ctx.RenderInternal.NodeSafeAttr("class", class)) } text := &html.Node{ diff --git a/modules/markup/html_codepreview.go b/modules/markup/html_codepreview.go index 5ab9290b3e42f..5c88481d769ef 100644 --- a/modules/markup/html_codepreview.go +++ b/modules/markup/html_codepreview.go @@ -6,7 +6,6 @@ package markup import ( "html/template" "net/url" - "regexp" "strconv" "strings" @@ -16,9 +15,6 @@ import ( "golang.org/x/net/html" ) -// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20" -var codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`) - type RenderCodePreviewOptions struct { FullURL string OwnerName string @@ -30,7 +26,7 @@ type RenderCodePreviewOptions struct { } func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosStop int, htm template.HTML, err error) { - m := codePreviewPattern.FindStringSubmatchIndex(node.Data) + m := globalVars().codePreviewPattern.FindStringSubmatchIndex(node.Data) if m == nil { return 0, 0, "", nil } @@ -66,8 +62,8 @@ func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) { node = node.NextSibling continue } - urlPosStart, urlPosEnd, h, err := renderCodeBlock(ctx, node) - if err != nil || h == "" { + urlPosStart, urlPosEnd, renderedCodeBlock, err := renderCodeBlock(ctx, node) + if err != nil || renderedCodeBlock == "" { if err != nil { log.Error("Unable to render code preview: %v", err) } @@ -84,7 +80,8 @@ func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) { // then it is resolved as: "{TextBefore}
{TextAfter}
", // so unless it could correctly replace the parent "p/li" node, it is very difficult to eliminate the "TextBefore" empty node. node.Data = textBefore - node.Parent.InsertBefore(&html.Node{Type: html.RawNode, Data: string(h)}, next) + renderedCodeNode := &html.Node{Type: html.RawNode, Data: string(ctx.RenderInternal.ProtectSafeAttrs(renderedCodeBlock))} + node.Parent.InsertBefore(renderedCodeNode, next) if textAfter != "" { node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next) } diff --git a/modules/markup/html_email.go b/modules/markup/html_email.go index 32d0285eb4c46..cbfae8b82940e 100644 --- a/modules/markup/html_email.go +++ b/modules/markup/html_email.go @@ -15,7 +15,7 @@ func emailAddressProcessor(ctx *RenderContext, node *html.Node) { } mail := node.Data[m[2]:m[3]] - replaceContent(node, m[2], m[3], createLink("mailto:"+mail, mail, "mailto")) + replaceContent(node, m[2], m[3], createLink(ctx, "mailto:"+mail, mail, "" /*mailto*/)) node = node.NextSibling.NextSibling } } diff --git a/modules/markup/html_emoji.go b/modules/markup/html_emoji.go index 6eacb2067f028..c63806542524c 100644 --- a/modules/markup/html_emoji.go +++ b/modules/markup/html_emoji.go @@ -13,15 +13,13 @@ import ( "golang.org/x/net/html/atom" ) -func createEmoji(content, class, name string) *html.Node { +func createEmoji(ctx *RenderContext, content, name string) *html.Node { span := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{}, } - if class != "" { - span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class}) - } + span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", "emoji")) if name != "" { span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name}) } @@ -35,13 +33,13 @@ func createEmoji(content, class, name string) *html.Node { return span } -func createCustomEmoji(alias string) *html.Node { +func createCustomEmoji(ctx *RenderContext, alias string) *html.Node { span := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{}, } - span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: "emoji"}) + span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", "emoji")) span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias}) img := &html.Node{ @@ -77,7 +75,7 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { if converted == nil { // check if this is a custom reaction if _, exist := setting.UI.CustomEmojisMap[alias]; exist { - replaceContent(node, m[0], m[1], createCustomEmoji(alias)) + replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) node = node.NextSibling.NextSibling start = 0 continue @@ -85,7 +83,7 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { continue } - replaceContent(node, m[0], m[1], createEmoji(converted.Emoji, "emoji", converted.Description)) + replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) node = node.NextSibling.NextSibling start = 0 } @@ -107,7 +105,7 @@ func emojiProcessor(ctx *RenderContext, node *html.Node) { start = m[1] val := emoji.FromCode(codepoint) if val != nil { - replaceContent(node, m[0], m[1], createEmoji(codepoint, "emoji", val.Description)) + replaceContent(node, m[0], m[1], createEmoji(ctx, codepoint, val.Description)) node = node.NextSibling.NextSibling start = 0 } diff --git a/modules/markup/html_issue.go b/modules/markup/html_issue.go index 2acf154ad2ad7..7341af7eb697b 100644 --- a/modules/markup/html_issue.go +++ b/modules/markup/html_issue.go @@ -57,10 +57,10 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) { matchRepo := linkParts[len(linkParts)-3] if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] { - replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) + replaceContent(node, m[0], m[1], createLink(ctx, link, text, "ref-issue")) } else { text = matchOrg + "/" + matchRepo + text - replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) + replaceContent(node, m[0], m[1], createLink(ctx, link, text, "ref-issue")) } node = node.NextSibling.NextSibling } @@ -129,16 +129,16 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) { log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err) } - link = createLink(res, reftext, "ref-issue ref-external-issue") + link = createLink(ctx, res, reftext, "ref-issue ref-external-issue") } else { // Path determines the type of link that will be rendered. It's unknown at this point whether // the linked item is actually a PR or an issue. Luckily it's of no real consequence because // Gitea will redirect on click as appropriate. issuePath := util.Iif(ref.IsPull, "pulls", "issues") if ref.Owner == "" { - link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue") + link = createLink(ctx, util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue") } else { - link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue") + link = createLink(ctx, util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue") } } @@ -151,7 +151,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) { // Decorate action keywords if actionable var keyword *html.Node if references.IsXrefActionable(ref, hasExtTrackFormat) { - keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End]) + keyword = createKeyword(ctx, node.Data[ref.ActionLocation.Start:ref.ActionLocation.End]) } else { keyword = &html.Node{ Type: html.TextNode, @@ -177,7 +177,7 @@ func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) { } reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha) - link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit") + link := createLink(ctx, util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit") replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link) node = node.NextSibling.NextSibling diff --git a/modules/markup/html_link.go b/modules/markup/html_link.go index b7562d0aa6d26..32aa7dc614ca7 100644 --- a/modules/markup/html_link.go +++ b/modules/markup/html_link.go @@ -189,13 +189,13 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { func linkProcessor(ctx *RenderContext, node *html.Node) { next := node.NextSibling for node != nil && node != next { - m := common.LinkRegex.FindStringIndex(node.Data) + m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data) if m == nil { return } uri := node.Data[m[0]:m[1]] - replaceContent(node, m[0], m[1], createLink(uri, uri, "link")) + replaceContent(node, m[0], m[1], createLink(ctx, uri, uri, "" /*link*/)) node = node.NextSibling.NextSibling } } @@ -204,7 +204,7 @@ func linkProcessor(ctx *RenderContext, node *html.Node) { func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) { next := node.NextSibling for node != nil && node != next { - m := common.LinkRegex.FindStringIndex(node.Data) + m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data) if m == nil { return } diff --git a/modules/markup/html_mention.go b/modules/markup/html_mention.go index 3f0692e05f55e..f7e2ad50f139f 100644 --- a/modules/markup/html_mention.go +++ b/modules/markup/html_mention.go @@ -33,7 +33,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) { if ok && strings.Contains(mention, "/") { mentionOrgAndTeam := strings.Split(mention, "/") if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") { - replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention")) + replaceContent(node, loc.Start, loc.End, createLink(ctx, util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "" /*mention*/)) node = node.NextSibling.NextSibling start = 0 continue @@ -44,7 +44,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) { mentionedUsername := mention[1:] if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) { - replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention")) + replaceContent(node, loc.Start, loc.End, createLink(ctx, util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "" /*mention*/)) node = node.NextSibling.NextSibling start = 0 } else { diff --git a/modules/markup/internal/finalprocessor.go b/modules/markup/internal/finalprocessor.go new file mode 100644 index 0000000000000..14d46a161f0b8 --- /dev/null +++ b/modules/markup/internal/finalprocessor.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package internal + +import ( + "bytes" + "io" +) + +type finalProcessor struct { + renderInternal *RenderInternal + + output io.Writer + buf bytes.Buffer +} + +func (p *finalProcessor) Write(data []byte) (int, error) { + p.buf.Write(data) + return len(data), nil +} + +func (p *finalProcessor) Close() error { + // TODO: reading the whole markdown isn't a problem at the moment, + // because "postProcess" already does so. In the future we could optimize the code to process data on the fly. + buf := p.buf.Bytes() + buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`)) + _, err := p.output.Write(buf) + return err +} diff --git a/modules/markup/internal/internal_test.go b/modules/markup/internal/internal_test.go new file mode 100644 index 0000000000000..98ff3bc079208 --- /dev/null +++ b/modules/markup/internal/internal_test.go @@ -0,0 +1,61 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package internal + +import ( + "bytes" + "html/template" + "io" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRenderInternal(t *testing.T) { + cases := []struct { + input, protected, recovered string + }{ + { + input: ``, strings.Join(preClasses, " "))
+ if err != nil {
+ return
+ }
+
+ // include language-x class as part of commonmark spec
+ // the "display" class is used by "js/markup/math.js" to render the code element as a block
+ err = r.ctx.RenderInternal.FormatWithSafeAttrs(w, ``, string(language))
+ if err != nil {
+ return
+ }
+ } else {
+ _, err := w.WriteString("
")
+ if err != nil {
+ return
+ }
+ }
+}
+
// SpecializedMarkdown sets up the Gitea specific markdown extensions
-func SpecializedMarkdown() goldmark.Markdown {
- specMarkdownOnce.Do(func() {
- specMarkdown = goldmark.New(
- goldmark.WithExtensions(
- extension.NewTable(
- extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
- extension.Strikethrough,
- extension.TaskList,
- extension.DefinitionList,
- common.FootnoteExtension,
- highlighting.NewHighlighting(
- highlighting.WithFormatOptions(
- chromahtml.WithClasses(true),
- chromahtml.PreventSurroundingPre(true),
- ),
- highlighting.WithWrapperRenderer(func(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
- if entering {
- language, _ := c.Language()
- if language == nil {
- language = []byte("text")
- }
-
- languageStr := string(language)
-
- preClasses := []string{"code-block"}
- if languageStr == "mermaid" || languageStr == "math" {
- preClasses = append(preClasses, "is-loading")
- }
-
- _, err := w.WriteString(``)
- if err != nil {
- return
- }
-
- // include language-x class as part of commonmark spec
- // the "display" class is used by "js/markup/math.js" to render the code element as a block
- _, err = w.WriteString(``)
- if err != nil {
- return
- }
- } else {
- _, err := w.WriteString("
")
- if err != nil {
- return
- }
- }
- }),
- ),
- math.NewExtension(
- math.Enabled(setting.Markdown.EnableMath),
- ),
- meta.Meta,
- ),
- goldmark.WithParserOptions(
- parser.WithAttribute(),
- parser.WithAutoHeadingID(),
- parser.WithASTTransformers(
- util.Prioritized(NewASTTransformer(), 10000),
+func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
+ // TODO: it could use a pool to cache the renderers to reuse them with different contexts
+ // at the moment it is fast enough (see the benchmarks)
+ r := &GlodmarkRender{ctx: ctx}
+ r.goldmarkMarkdown = goldmark.New(
+ goldmark.WithExtensions(
+ extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
+ extension.Strikethrough,
+ extension.TaskList,
+ extension.DefinitionList,
+ common.FootnoteExtension,
+ highlighting.NewHighlighting(
+ highlighting.WithFormatOptions(
+ chromahtml.WithClasses(true),
+ chromahtml.PreventSurroundingPre(true),
),
+ highlighting.WithWrapperRenderer(r.highlightingRenderer),
),
- goldmark.WithRendererOptions(
- html.WithUnsafe(),
- ),
- )
-
- // Override the original Tasklist renderer!
- specMarkdown.Renderer().AddOptions(
- renderer.WithNodeRenderers(
- util.Prioritized(NewHTMLRenderer(), 10),
- ),
- )
- })
- return specMarkdown
+ math.NewExtension(&ctx.RenderInternal, math.Enabled(setting.Markdown.EnableMath)),
+ meta.Meta,
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAttribute(),
+ parser.WithAutoHeadingID(),
+ parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
+ ),
+ goldmark.WithRendererOptions(html.WithUnsafe()),
+ )
+
+ // Override the original Tasklist renderer!
+ r.goldmarkMarkdown.Renderer().AddOptions(
+ renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
+ )
+
+ return r
}
-// actualRender renders Markdown to HTML without handling special links.
-func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
- converter := SpecializedMarkdown()
+// render calls goldmark render to convert Markdown to HTML
+// NOTE: The output of this method MUST get sanitized separately!!!
+func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
+ converter := SpecializedMarkdown(ctx)
lw := &limitWriter{
w: output,
limit: setting.UI.MaxDisplayFileSize * 3,
@@ -160,8 +164,8 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
}
log.Warn("Unable to render markdown due to panic in goldmark: %v", err)
- if log.IsDebug() {
- log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2))
+ if (!setting.IsProd && !setting.IsInTesting) || log.IsDebug() {
+ log.Error("Panic in markdown: %v\n%s", err, log.Stack(2))
}
}()
@@ -200,26 +204,6 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
return nil
}
-// Note: The output of this method must get sanitized.
-func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
- defer func() {
- err := recover()
- if err == nil {
- return
- }
-
- log.Warn("Unable to render markdown due to panic in goldmark - will return raw bytes")
- if log.IsDebug() {
- log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2))
- }
- _, err = io.Copy(output, input)
- if err != nil {
- log.Error("io.Copy failed: %v", err)
- }
- }()
- return actualRender(ctx, input, output)
-}
-
// MarkupName describes markup's name
var MarkupName = "markdown"
diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go
index 780df8727f0c8..e4889a75e59fd 100644
--- a/modules/markup/markdown/markdown_test.go
+++ b/modules/markup/markdown/markdown_test.go
@@ -1051,3 +1051,17 @@ func TestAttention(t *testing.T) {
// legacy GitHub style
test(`> **warning**`, renderAttention("warning", "octicon-alert")+"\n")
}
+
+func BenchmarkSpecializedMarkdown(b *testing.B) {
+ // 240856 4719 ns/op
+ for i := 0; i < b.N; i++ {
+ markdown.SpecializedMarkdown(&markup.RenderContext{})
+ }
+}
+
+func BenchmarkMarkdownRender(b *testing.B) {
+ // 23202 50840 ns/op
+ for i := 0; i < b.N; i++ {
+ _, _ = markdown.RenderString(&markup.RenderContext{Ctx: context.Background()}, "https://example.com\n- a\n- b\n")
+ }
+}
diff --git a/modules/markup/markdown/math/block_renderer.go b/modules/markup/markdown/math/block_renderer.go
index 84817ef1e4a51..0d2a966102e95 100644
--- a/modules/markup/markdown/math/block_renderer.go
+++ b/modules/markup/markdown/math/block_renderer.go
@@ -4,17 +4,21 @@
package math
import (
+ "code.gitea.io/gitea/modules/markup/internal"
+
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// BlockRenderer represents a renderer for math Blocks
-type BlockRenderer struct{}
+type BlockRenderer struct {
+ renderInternal *internal.RenderInternal
+}
// NewBlockRenderer creates a new renderer for math Blocks
-func NewBlockRenderer() renderer.NodeRenderer {
- return &BlockRenderer{}
+func NewBlockRenderer(renderInternal *internal.RenderInternal) renderer.NodeRenderer {
+ return &BlockRenderer{renderInternal: renderInternal}
}
// RegisterFuncs registers the renderer for math Blocks
@@ -33,7 +37,7 @@ func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node)
func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
n := node.(*Block)
if entering {
- _, _ = w.WriteString(``)
+ _ = r.renderInternal.FormatWithSafeAttrs(w, ``)
r.writeLines(w, source, n)
} else {
_, _ = w.WriteString(`
` + "\n")
diff --git a/modules/markup/markdown/math/inline_renderer.go b/modules/markup/markdown/math/inline_renderer.go
index 96848099cce23..0cff4f1e74e11 100644
--- a/modules/markup/markdown/math/inline_renderer.go
+++ b/modules/markup/markdown/math/inline_renderer.go
@@ -6,17 +6,21 @@ package math
import (
"bytes"
+ "code.gitea.io/gitea/modules/markup/internal"
+
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// InlineRenderer is an inline renderer
-type InlineRenderer struct{}
+type InlineRenderer struct {
+ renderInternal *internal.RenderInternal
+}
// NewInlineRenderer returns a new renderer for inline math
-func NewInlineRenderer() renderer.NodeRenderer {
- return &InlineRenderer{}
+func NewInlineRenderer(renderInternal *internal.RenderInternal) renderer.NodeRenderer {
+ return &InlineRenderer{renderInternal: renderInternal}
}
func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
@@ -25,7 +29,7 @@ func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Nod
if _, ok := n.(*InlineBlock); ok {
extraClass = "display "
}
- _, _ = w.WriteString(``)
+ _ = r.renderInternal.FormatWithSafeAttrs(w, ``, extraClass)
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
segment := c.(*ast.Text).Segment
value := util.EscapeHTML(segment.Value(source))
diff --git a/modules/markup/markdown/math/math.go b/modules/markup/markdown/math/math.go
index 3d9f376bc60e4..7e8defcd4a1e7 100644
--- a/modules/markup/markdown/math/math.go
+++ b/modules/markup/markdown/math/math.go
@@ -4,6 +4,8 @@
package math
import (
+ "code.gitea.io/gitea/modules/markup/internal"
+
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
@@ -12,6 +14,7 @@ import (
// Extension is a math extension
type Extension struct {
+ renderInternal *internal.RenderInternal
enabled bool
parseDollarInline bool
parseDollarBlock bool
@@ -39,38 +42,10 @@ func Enabled(enable ...bool) Option {
})
}
-// WithInlineDollarParser enables or disables the parsing of $...$
-func WithInlineDollarParser(enable ...bool) Option {
- value := true
- if len(enable) > 0 {
- value = enable[0]
- }
- return extensionFunc(func(e *Extension) {
- e.parseDollarInline = value
- })
-}
-
-// WithBlockDollarParser enables or disables the parsing of $$...$$
-func WithBlockDollarParser(enable ...bool) Option {
- value := true
- if len(enable) > 0 {
- value = enable[0]
- }
- return extensionFunc(func(e *Extension) {
- e.parseDollarBlock = value
- })
-}
-
-// Math represents a math extension with default rendered delimiters
-var Math = &Extension{
- enabled: true,
- parseDollarBlock: true,
- parseDollarInline: true,
-}
-
// NewExtension creates a new math extension with the provided options
-func NewExtension(opts ...Option) *Extension {
+func NewExtension(renderInternal *internal.RenderInternal, opts ...Option) *Extension {
r := &Extension{
+ renderInternal: renderInternal,
enabled: true,
parseDollarBlock: true,
parseDollarInline: true,
@@ -102,7 +77,7 @@ func (e *Extension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(inlines...))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
- util.Prioritized(NewBlockRenderer(), 501),
- util.Prioritized(NewInlineRenderer(), 502),
+ util.Prioritized(NewBlockRenderer(e.renderInternal), 501),
+ util.Prioritized(NewInlineRenderer(e.renderInternal), 502),
))
}
diff --git a/modules/markup/markdown/meta_test.go b/modules/markup/markdown/meta_test.go
index 6949966328c4b..278c33f1d2f07 100644
--- a/modules/markup/markdown/meta_test.go
+++ b/modules/markup/markdown/meta_test.go
@@ -11,10 +11,8 @@ import (
"github.com/stretchr/testify/assert"
)
-/*
-IssueTemplate is a legacy to keep the unit tests working.
-Copied from structs.IssueTemplate, the original type has been changed a lot to support yaml template.
-*/
+// IssueTemplate is a legacy to keep the unit tests working.
+// Copied from structs.IssueTemplate, the original type has been changed a lot to support yaml template.
type IssueTemplate struct {
Name string `json:"name" yaml:"name"`
Title string `json:"title" yaml:"title"`
diff --git a/modules/markup/markdown/transform_blockquote.go b/modules/markup/markdown/transform_blockquote.go
index 92dc500e69625..2651d44a69ff9 100644
--- a/modules/markup/markdown/transform_blockquote.go
+++ b/modules/markup/markdown/transform_blockquote.go
@@ -32,7 +32,8 @@ func (r *HTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast
default: // including "note"
octiconName = "info"
}
- _, _ = w.WriteString(string(svg.RenderHTML("octicon-"+octiconName, 16, "attention-icon attention-"+n.AttentionType)))
+ svgHTML := svg.RenderHTML("octicon-"+octiconName, 16, "attention-icon attention-"+n.AttentionType)
+ _, _ = w.WriteString(string(r.renderInternal.ProtectSafeAttrs(svgHTML)))
}
return ast.WalkContinue, nil
}
@@ -128,13 +129,13 @@ func (g *ASTTransformer) transformBlockquote(v *ast.Blockquote, reader text.Read
}
// color the blockquote
- v.SetAttributeString("class", []byte("attention-header attention-"+attentionType))
+ v.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("attention-header attention-"+attentionType)))
// create an emphasis to make it bold
attentionParagraph := ast.NewParagraph()
g.applyElementDir(attentionParagraph)
emphasis := ast.NewEmphasis(2)
- emphasis.SetAttributeString("class", []byte("attention-"+attentionType))
+ emphasis.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("attention-"+attentionType)))
attentionAstString := ast.NewString([]byte(cases.Title(language.English).String(attentionType)))
diff --git a/modules/markup/markdown/transform_codespan.go b/modules/markup/markdown/transform_codespan.go
index ff7d24eec97c9..bccc43aad2510 100644
--- a/modules/markup/markdown/transform_codespan.go
+++ b/modules/markup/markdown/transform_codespan.go
@@ -5,7 +5,6 @@ package markdown
import (
"bytes"
- "fmt"
"strings"
"code.gitea.io/gitea/modules/markup"
@@ -40,7 +39,7 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod
r.Writer.RawWrite(w, value)
}
case *ColorPreview:
- _, _ = w.WriteString(fmt.Sprintf(``, string(v.Color)))
+ _ = r.renderInternal.FormatWithSafeAttrs(w, ``, string(v.Color))
}
}
return ast.WalkSkipChildren, nil
diff --git a/modules/markup/markdown/transform_list.go b/modules/markup/markdown/transform_list.go
index b982fd4a8306a..c89ad2f2cf34b 100644
--- a/modules/markup/markdown/transform_list.go
+++ b/modules/markup/markdown/transform_list.go
@@ -72,7 +72,7 @@ func (g *ASTTransformer) transformList(_ *markup.RenderContext, v *ast.List, rc
}
newChild := NewTaskCheckBoxListItem(listItem)
newChild.IsChecked = taskCheckBox.IsChecked
- newChild.SetAttributeString("class", []byte("task-list-item"))
+ newChild.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("task-list-item")))
segments := newChild.FirstChild().Lines()
if segments.Len() > 0 {
segment := segments.At(0)
diff --git a/modules/markup/render.go b/modules/markup/render.go
index 1977dc73f55ef..f05cb62626451 100644
--- a/modules/markup/render.go
+++ b/modules/markup/render.go
@@ -9,14 +9,15 @@ import (
"io"
"net/url"
"strings"
- "sync"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/yuin/goldmark/ast"
+ "golang.org/x/sync/errgroup"
)
type RenderMetaMode string
@@ -65,6 +66,8 @@ type RenderContext struct {
SidebarTocNode ast.Node
RenderMetaAs RenderMetaMode
InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
+
+ RenderInternal internal.RenderInternal
}
// Cancel runs any cleanup functions that have been registered for this Ctx
@@ -156,59 +159,53 @@ sandbox="allow-scripts"
return err
}
-func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
- var wg sync.WaitGroup
- var err error
+func pipes() (io.ReadCloser, io.WriteCloser, func()) {
pr, pw := io.Pipe()
- defer func() {
+ return pr, pw, func() {
_ = pr.Close()
_ = pw.Close()
- }()
-
- var pr2 io.ReadCloser
- var pw2 io.WriteCloser
-
- var sanitizerDisabled bool
- if r, ok := renderer.(ExternalRenderer); ok {
- sanitizerDisabled = r.SanitizerDisabled()
}
+}
- if !sanitizerDisabled {
- pr2, pw2 = io.Pipe()
- defer func() {
- _ = pr2.Close()
- _ = pw2.Close()
- }()
-
- wg.Add(1)
- go func() {
- err = SanitizeReader(pr2, renderer.Name(), output)
- _ = pr2.Close()
- wg.Done()
- }()
- } else {
- pw2 = util.NopCloser{Writer: output}
+func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
+ finalProcessor := ctx.RenderInternal.Init(output)
+ defer finalProcessor.Close()
+
+ // input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
+ // no sanitizer: input -> (pw1=pr1) -> renderer -> pw2(finalProcessor) -> output
+ pr1, pw1, close1 := pipes()
+ defer close1()
+
+ eg, _ := errgroup.WithContext(ctx.Ctx)
+ var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
+
+ if r, ok := renderer.(ExternalRenderer); !ok || !r.SanitizerDisabled() {
+ var pr2 io.ReadCloser
+ var close2 func()
+ pr2, pw2, close2 = pipes()
+ defer close2()
+ eg.Go(func() error {
+ defer pr2.Close()
+ return SanitizeReader(pr2, renderer.Name(), finalProcessor)
+ })
}
- wg.Add(1)
- go func() {
+ eg.Go(func() (err error) {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
- err = PostProcess(ctx, pr, pw2)
+ err = PostProcess(ctx, pr1, pw2)
} else {
- _, err = io.Copy(pw2, pr)
+ _, err = io.Copy(pw2, pr1)
}
- _ = pr.Close()
- _ = pw2.Close()
- wg.Done()
- }()
+ _, _ = pr1.Close(), pw2.Close()
+ return err
+ })
- if err1 := renderer.Render(ctx, input, pw); err1 != nil {
- return err1
+ if err := renderer.Render(ctx, input, pw1); err != nil {
+ return err
}
- _ = pw.Close()
+ _ = pw1.Close()
- wg.Wait()
- return err
+ return eg.Wait()
}
// Init initializes the render global variables
diff --git a/modules/markup/sanitizer_custom.go b/modules/markup/sanitizer_custom.go
index 7978973166f6f..7f96556fd7954 100644
--- a/modules/markup/sanitizer_custom.go
+++ b/modules/markup/sanitizer_custom.go
@@ -4,6 +4,9 @@
package markup
import (
+ "regexp"
+ "strings"
+
"code.gitea.io/gitea/modules/setting"
"github.com/microcosm-cc/bluemonday"
@@ -15,8 +18,11 @@ func (st *Sanitizer) addSanitizerRules(policy *bluemonday.Policy, rules []settin
policy.AllowDataURIImages()
}
if rule.Element != "" {
- if rule.Regexp != nil {
- policy.AllowAttrs(rule.AllowAttr).Matching(rule.Regexp).OnElements(rule.Element)
+ if rule.Regexp != "" {
+ if !strings.HasPrefix(rule.Regexp, "^") || !strings.HasSuffix(rule.Regexp, "$") {
+ panic("Markup sanitizer rule regexp must start with ^ and end with $ to be strict")
+ }
+ policy.AllowAttrs(rule.AllowAttr).Matching(regexp.MustCompile(rule.Regexp)).OnElements(rule.Element)
} else {
policy.AllowAttrs(rule.AllowAttr).OnElements(rule.Element)
}
diff --git a/modules/markup/sanitizer_default.go b/modules/markup/sanitizer_default.go
index 476ae5e26f0c1..0fa54efd45ef9 100644
--- a/modules/markup/sanitizer_default.go
+++ b/modules/markup/sanitizer_default.go
@@ -16,36 +16,11 @@ import (
func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy()
- // For JS code copy and Mermaid loading state
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
-
- // For code preview
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-preview-[-\w]+( file-content)?$`)).Globally()
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-num$`)).OnElements("td")
- policy.AllowAttrs("data-line-number").OnElements("span")
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-code chroma$`)).OnElements("td")
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-inner$`)).OnElements("div")
-
- // For code preview (unicode escape)
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^file-view( unicode-escaped)?$`)).OnElements("table")
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-escape$`)).OnElements("td")
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^toggle-escape-button btn interact-bg$`)).OnElements("a") // don't use button, button might submit a form
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ambiguous-code-point|escaped-code-point|broken-code-point)$`)).OnElements("span")
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^char$`)).OnElements("span")
- policy.AllowAttrs("data-tooltip-content", "data-escaped").OnElements("span")
-
- // For color preview
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
-
- // For attention
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-header attention-\w+$`)).OnElements("blockquote")
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-\w+$`)).OnElements("strong")
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-icon attention-\w+ svg octicon-[\w-]+$`)).OnElements("svg")
- policy.AllowAttrs("viewBox", "width", "height", "aria-hidden").OnElements("svg")
- policy.AllowAttrs("fill-rule", "d").OnElements("path")
+ // NOTICE: DO NOT add special "class" regexp rules here anymore, use RenderInternal.SafeAttr instead
- // For Chroma markdown plugin
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code")
+ // General safe SVG attributes
+ policy.AllowAttrs("viewBox", "width", "height", "aria-hidden", "data-attr-class").OnElements("svg")
+ policy.AllowAttrs("fill-rule", "d").OnElements("path")
// Checkboxes
policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
@@ -66,28 +41,15 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
policy.AllowURLSchemeWithCustomPolicy("data", disallowScheme)
}
- // Allow classes for anchors
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`ref-issue( ref-external-issue)?`)).OnElements("a")
-
- // Allow classes for task lists
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`task-list-item`)).OnElements("li")
-
// Allow classes for org mode list item status.
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(unchecked|checked|indeterminate)$`)).OnElements("li")
- // Allow icons
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^icon(\s+[\p{L}\p{N}_-]+)+$`)).OnElements("i")
-
- // Allow classes for emojis
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`emoji`)).OnElements("img")
-
- // Allow icons, emojis, chroma syntax and keyword markup on span
- policy.AllowAttrs("class").Matching(regexp.MustCompile(`^((icon(\s+[\p{L}\p{N}_-]+)+)|(emoji)|(language-math display)|(language-math inline))$|^([a-z][a-z0-9]{0,2})$|^` + keywordClass + `$`)).OnElements("span")
-
// Allow 'color' and 'background-color' properties for the style attribute on text elements.
policy.AllowStyles("color", "background-color").OnElements("span", "p")
- // Allow generally safe attributes
+ policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
+
+ // Allow generally safe attributes (reference: https://github.com/jch/html-pipeline)
generalSafeAttrs := []string{
"abbr", "accept", "accept-charset",
"accesskey", "action", "align", "alt",
@@ -106,10 +68,9 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
"selected", "shape", "size", "span",
"start", "summary", "tabindex", "target",
"title", "type", "usemap", "valign", "value",
- "vspace", "width", "itemprop",
- "data-markdown-generated-content",
+ "vspace", "width", "itemprop", "itemscope", "itemtype",
+ "data-markdown-generated-content", "data-attr-class",
}
-
generalSafeElements := []string{
"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
"div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label",
@@ -117,14 +78,8 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
"details", "caption", "figure", "figcaption",
"abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr",
}
-
- policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
-
- policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
-
- policy.AllowAttrs("itemscope", "itemtype").OnElements("div")
-
// FIXME: Need to handle longdesc in img but there is no easy way to do it
+ policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
// Custom keyword markup
defaultSanitizer.addSanitizerRules(policy, setting.ExternalSanitizerRules)
diff --git a/modules/markup/sanitizer_default_test.go b/modules/markup/sanitizer_default_test.go
index 20370509c134f..c5c43695ea08a 100644
--- a/modules/markup/sanitizer_default_test.go
+++ b/modules/markup/sanitizer_default_test.go
@@ -19,7 +19,6 @@ func TestSanitizer(t *testing.T) {
// Code highlighting class
`
`, `
`,
`
`, `
`,
- `
`, `
`,
// Input checkbox
``, ``,
@@ -38,10 +37,8 @@ func TestSanitizer(t *testing.T) {
// tags
`Ctrl + C`, `Ctrl + C`,
`NAUGHTY`, `NAUGHTY`,
- ``, ``,
`unchecked`, `unchecked`,
`NAUGHTY`, `NAUGHTY`,
- `contents`, `contents`,
// Color property
`Hello World`, `Hello World`,
diff --git a/modules/setting/markup.go b/modules/setting/markup.go
index 6c2246342be8e..dfce8afa77f82 100644
--- a/modules/setting/markup.go
+++ b/modules/setting/markup.go
@@ -54,7 +54,7 @@ type MarkupRenderer struct {
type MarkupSanitizerRule struct {
Element string
AllowAttr string
- Regexp *regexp.Regexp
+ Regexp string
AllowDataURIImages bool
}
@@ -117,15 +117,24 @@ func createMarkupSanitizerRule(name string, sec ConfigSection) (MarkupSanitizerR
regexpStr := sec.Key("REGEXP").Value()
if regexpStr != "" {
- // Validate when parsing the config that this is a valid regular
- // expression. Then we can use regexp.MustCompile(...) later.
- compiled, err := regexp.Compile(regexpStr)
+ hasPrefix := strings.HasPrefix(regexpStr, "^")
+ hasSuffix := strings.HasSuffix(regexpStr, "$")
+ if !hasPrefix || !hasSuffix {
+ log.Error("In markup.%s: REGEXP must start with ^ and end with $ to be strict", name)
+ // to avoid breaking existing user configurations and satisfy the strict requirement in addSanitizerRules
+ if !hasPrefix {
+ regexpStr = "^.*" + regexpStr
+ }
+ if !hasSuffix {
+ regexpStr += ".*$"
+ }
+ }
+ _, err := regexp.Compile(regexpStr)
if err != nil {
log.Error("In markup.%s: REGEXP (%s) failed to compile: %v", name, regexpStr, err)
return rule, false
}
-
- rule.Regexp = compiled
+ rule.Regexp = regexpStr
}
ok = true
diff --git a/modules/svg/svg.go b/modules/svg/svg.go
index 8132978caca99..fded9d0873744 100644
--- a/modules/svg/svg.go
+++ b/modules/svg/svg.go
@@ -9,7 +9,7 @@ import (
"path"
"strings"
- gitea_html "code.gitea.io/gitea/modules/html"
+ gitea_html "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
)
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 3ef11772dc7d7..d5b32358da752 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -10,12 +10,12 @@ import (
"html/template"
"net/url"
"reflect"
- "slices"
"strings"
"time"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
@@ -39,7 +39,7 @@ func NewFuncMap() template.FuncMap {
"Iif": iif,
"Eval": evalTokens,
"SafeHTML": safeHTML,
- "HTMLFormat": HTMLFormat,
+ "HTMLFormat": htmlutil.HTMLFormat,
"HTMLEscape": htmlEscape,
"QueryEscape": queryEscape,
"JSEscape": jsEscapeSafe,
@@ -184,23 +184,6 @@ func NewFuncMap() template.FuncMap {
}
}
-func HTMLFormat(s string, rawArgs ...any) template.HTML {
- args := slices.Clone(rawArgs)
- for i, v := range args {
- switch v := v.(type) {
- case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
- // for most basic types (including template.HTML which is safe), just do nothing and use it
- case string:
- args[i] = template.HTMLEscapeString(v)
- case fmt.Stringer:
- args[i] = template.HTMLEscapeString(v.String())
- default:
- args[i] = template.HTMLEscapeString(fmt.Sprint(v))
- }
- }
- return template.HTML(fmt.Sprintf(s, args...))
-}
-
// safeHTML render raw as HTML
func safeHTML(s any) template.HTML {
switch v := s.(type) {
diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go
index b9fabb7016460..3e17e86c66256 100644
--- a/modules/templates/helper_test.go
+++ b/modules/templates/helper_test.go
@@ -61,10 +61,6 @@ func TestJSEscapeSafe(t *testing.T) {
assert.EqualValues(t, `\u0026\u003C\u003E\'\"`, jsEscapeSafe(`&<>'"`))
}
-func TestHTMLFormat(t *testing.T) {
- assert.Equal(t, template.HTML("< < 1"), HTMLFormat("%s %s %d", "<", template.HTML("<"), 1))
-}
-
func TestSanitizeHTML(t *testing.T) {
assert.Equal(t, template.HTML(`link xss inline`), SanitizeHTML(`link xss inline`))
}
diff --git a/modules/templates/util_avatar.go b/modules/templates/util_avatar.go
index afc10915163bc..f7dd408ee2133 100644
--- a/modules/templates/util_avatar.go
+++ b/modules/templates/util_avatar.go
@@ -14,7 +14,7 @@ import (
"code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
- gitea_html "code.gitea.io/gitea/modules/html"
+ gitea_html "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/setting"
)
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 8e443446bd69c..5776eefced961 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -16,6 +16,7 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/emoji"
+ "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
@@ -140,7 +141,7 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
if labelScope == "" {
// Regular label
- return HTMLFormat(`%s`,
+ return htmlutil.HTMLFormat(`%s`,
extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name))
}
@@ -174,7 +175,7 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
itemColor := "#" + hex.EncodeToString(itemBytes)
scopeColor := "#" + hex.EncodeToString(scopeBytes)
- return HTMLFormat(``+
+ return htmlutil.HTMLFormat(``+
`%s`+
`%s`+
``,
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
index 529507e7eab82..cf6d839cbf686 100644
--- a/modules/templates/util_render_test.go
+++ b/modules/templates/util_render_test.go
@@ -113,34 +113,34 @@ func TestRenderCommitBody(t *testing.T) {
}
expected := `/just/a/path.bin
-https://example.com/file.bin
+https://example.com/file.bin
[local link](file.bin)
-[remote link](https://example.com)
+[remote link](https://example.com)
[[local link|file.bin]]
-[[remote link|https://example.com]]
+[[remote link|https://example.com]]
![local image](image.jpg)
-![remote image](https://example.com/image.jpg)
+![remote image](https://example.com/image.jpg)
[[local image|image.jpg]]
-[[remote link|https://example.com/image.jpg]]
+[[remote link|https://example.com/image.jpg]]
88fc37a3c0...12fc37a3c0 (hash)
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
88fc37a3c0
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
👍
-mail@domain.com
-@mention-user test
+mail@domain.com
+@mention-user test
#123
space`
assert.EqualValues(t, expected, string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)))
}
func TestRenderCommitMessage(t *testing.T) {
- expected := `space @mention-user `
+ expected := `space @mention-user `
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas))
}
func TestRenderCommitMessageLinkSubject(t *testing.T) {
- expected := `space @mention-user`
+ expected := `space @mention-user`
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index c3639fb72e2f3..122b70924a9ca 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -352,6 +352,7 @@ enable_update_checker = Enable Update Checker
enable_update_checker_helper = Checks for new version releases periodically by connecting to gitea.io.
env_config_keys = Environment Configuration
env_config_keys_prompt = The following environment variables will also be applied to your configuration file:
+config_write_file_prompt = These configuration options will be written into:
[home]
nav_menu = Navigation Menu
diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go
index bb16858c81160..1cea7d8c72e2e 100644
--- a/routers/api/v1/repo/branch.go
+++ b/routers/api/v1/repo/branch.go
@@ -133,11 +133,6 @@ func DeleteBranch(ctx *context.APIContext) {
branchName := ctx.PathParam("*")
- if ctx.Repo.Repository.IsEmpty {
- ctx.Error(http.StatusForbidden, "", "Git Repository is empty.")
- return
- }
-
// check whether branches of this repository has been synced
totalNumOfBranches, err := db.Count[git_model.Branch](ctx, git_model.FindBranchOptions{
RepoID: ctx.Repo.Repository.ID,
diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go
index a1e3c9804ba39..14a1a8d1c4a3d 100644
--- a/routers/api/v1/repo/fork.go
+++ b/routers/api/v1/repo/fork.go
@@ -55,11 +55,20 @@ func ListForks(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
- forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx))
+ forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx))
if err != nil {
- ctx.Error(http.StatusInternalServerError, "GetForks", err)
+ ctx.Error(http.StatusInternalServerError, "FindForks", err)
return
}
+ if err := repo_model.RepositoryList(forks).LoadOwners(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadOwners", err)
+ return
+ }
+ if err := repo_model.RepositoryList(forks).LoadUnits(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadUnits", err)
+ return
+ }
+
apiForks := make([]*api.Repository, len(forks))
for i, fork := range forks {
permission, err := access_model.GetUserRepoPermission(ctx, fork, ctx.Doer)
@@ -70,7 +79,7 @@ func ListForks(ctx *context.APIContext) {
apiForks[i] = convert.ToRepo(ctx, fork, permission)
}
- ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumForks))
+ ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, apiForks)
}
diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go
index faea34959fb5b..2ccc4a2253742 100644
--- a/routers/web/auth/oauth2_provider.go
+++ b/routers/web/auth/oauth2_provider.go
@@ -80,12 +80,12 @@ func (err errCallback) Error() string {
}
type userInfoResponse struct {
- Sub string `json:"sub"`
- Name string `json:"name"`
- Username string `json:"preferred_username"`
- Email string `json:"email"`
- Picture string `json:"picture"`
- Groups []string `json:"groups"`
+ Sub string `json:"sub"`
+ Name string `json:"name"`
+ PreferredUsername string `json:"preferred_username"`
+ Email string `json:"email"`
+ Picture string `json:"picture"`
+ Groups []string `json:"groups"`
}
// InfoOAuth manages request for userinfo endpoint
@@ -97,11 +97,11 @@ func InfoOAuth(ctx *context.Context) {
}
response := &userInfoResponse{
- Sub: fmt.Sprint(ctx.Doer.ID),
- Name: ctx.Doer.FullName,
- Username: ctx.Doer.Name,
- Email: ctx.Doer.Email,
- Picture: ctx.Doer.AvatarLink(ctx),
+ Sub: fmt.Sprint(ctx.Doer.ID),
+ Name: ctx.Doer.DisplayName(),
+ PreferredUsername: ctx.Doer.Name,
+ Email: ctx.Doer.Email,
+ Picture: ctx.Doer.AvatarLink(ctx),
}
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
diff --git a/routers/web/auth/oauth_test.go b/routers/web/auth/oauth_test.go
index 78af97fa9c669..8d9365fab453e 100644
--- a/routers/web/auth/oauth_test.go
+++ b/routers/web/auth/oauth_test.go
@@ -10,7 +10,6 @@ import (
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
- "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
@@ -66,25 +65,7 @@ func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
// Scopes: openid profile email
oidcToken = createAndParseToken(t, grants[0])
- assert.Equal(t, user.Name, oidcToken.Name)
- assert.Equal(t, user.Name, oidcToken.PreferredUsername)
- assert.Equal(t, user.HTMLURL(), oidcToken.Profile)
- assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture)
- assert.Equal(t, user.Website, oidcToken.Website)
- assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt)
- assert.Equal(t, user.Email, oidcToken.Email)
- assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
-
- // set DefaultShowFullName to true
- oldDefaultShowFullName := setting.UI.DefaultShowFullName
- setting.UI.DefaultShowFullName = true
- defer func() {
- setting.UI.DefaultShowFullName = oldDefaultShowFullName
- }()
-
- // Scopes: openid profile email
- oidcToken = createAndParseToken(t, grants[0])
- assert.Equal(t, user.FullName, oidcToken.Name)
+ assert.Equal(t, user.DisplayName(), oidcToken.Name)
assert.Equal(t, user.Name, oidcToken.PreferredUsername)
assert.Equal(t, user.HTMLURL(), oidcToken.Profile)
assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture)
diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go
index 485bd927fa932..e30129bb44ccd 100644
--- a/routers/web/repo/setting/setting.go
+++ b/routers/web/repo/setting/setting.go
@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"net/http"
- "strconv"
"strings"
"time"
@@ -290,8 +289,8 @@ func SettingsPost(ctx *context.Context) {
return
}
- m, err := selectPushMirrorByForm(ctx, form, repo)
- if err != nil {
+ m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID)
+ if m == nil {
ctx.NotFound("", nil)
return
}
@@ -317,15 +316,13 @@ func SettingsPost(ctx *context.Context) {
return
}
- id, err := strconv.ParseInt(form.PushMirrorID, 10, 64)
- if err != nil {
- ctx.ServerError("UpdatePushMirrorIntervalPushMirrorID", err)
+ m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID)
+ if m == nil {
+ ctx.NotFound("", nil)
return
}
- m := &repo_model.PushMirror{
- ID: id,
- Interval: interval,
- }
+
+ m.Interval = interval
if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil {
ctx.ServerError("UpdatePushMirrorInterval", err)
return
@@ -334,7 +331,10 @@ func SettingsPost(ctx *context.Context) {
// If we observed its implementation in the context of `push-mirror-sync` where it
// is evident that pushing to the queue is necessary for updates.
// So, there are updates within the given interval, it is necessary to update the queue accordingly.
- mirror_service.AddPushMirrorToQueue(m.ID)
+ if !ctx.FormBool("push_mirror_defer_sync") {
+ // push_mirror_defer_sync is mainly for testing purpose, we do not really want to sync the push mirror immediately
+ mirror_service.AddPushMirrorToQueue(m.ID)
+ }
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
ctx.Redirect(repo.Link() + "/settings")
@@ -348,18 +348,18 @@ func SettingsPost(ctx *context.Context) {
// as an error on the UI for this action
ctx.Data["Err_RepoName"] = nil
- m, err := selectPushMirrorByForm(ctx, form, repo)
- if err != nil {
+ m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID)
+ if m == nil {
ctx.NotFound("", nil)
return
}
- if err = mirror_service.RemovePushMirrorRemote(ctx, m); err != nil {
+ if err := mirror_service.RemovePushMirrorRemote(ctx, m); err != nil {
ctx.ServerError("RemovePushMirrorRemote", err)
return
}
- if err = repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
+ if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
ctx.ServerError("DeletePushMirrorByID", err)
return
}
@@ -995,24 +995,3 @@ func handleSettingRemoteAddrError(ctx *context.Context, err error, form *forms.R
}
ctx.RenderWithErr(ctx.Tr("repo.mirror_address_url_invalid"), tplSettingsOptions, form)
}
-
-func selectPushMirrorByForm(ctx *context.Context, form *forms.RepoSettingForm, repo *repo_model.Repository) (*repo_model.PushMirror, error) {
- id, err := strconv.ParseInt(form.PushMirrorID, 10, 64)
- if err != nil {
- return nil, err
- }
-
- pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{})
- if err != nil {
- return nil, err
- }
-
- for _, m := range pushMirrors {
- if m.ID == id {
- m.Repo = repo
- return m, nil
- }
- }
-
- return nil, fmt.Errorf("PushMirror[%v] not associated to repository %v", id, repo)
-}
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index 7030f6d8a982a..5d68ace29b535 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -1151,26 +1151,25 @@ func Forks(ctx *context.Context) {
if page <= 0 {
page = 1
}
+ pageSize := setting.ItemsPerPage
- pager := context.NewPagination(ctx.Repo.Repository.NumForks, setting.ItemsPerPage, page, 5)
- ctx.Data["Page"] = pager
-
- forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, db.ListOptions{
- Page: pager.Paginater.Current(),
- PageSize: setting.ItemsPerPage,
+ forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, db.ListOptions{
+ Page: page,
+ PageSize: pageSize,
})
if err != nil {
- ctx.ServerError("GetForks", err)
+ ctx.ServerError("FindForks", err)
return
}
- for _, fork := range forks {
- if err = fork.LoadOwner(ctx); err != nil {
- ctx.ServerError("LoadOwner", err)
- return
- }
+ if err := repo_model.RepositoryList(forks).LoadOwners(ctx); err != nil {
+ ctx.ServerError("LoadAttributes", err)
+ return
}
+ pager := context.NewPagination(int(total), pageSize, page, 5)
+ ctx.Data["Page"] = pager
+
ctx.Data["Forks"] = forks
ctx.HTML(http.StatusOK, tplForks)
diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go
index 13f6b69493e09..2732a67e714fd 100644
--- a/routers/web/repo/wiki.go
+++ b/routers/web/repo/wiki.go
@@ -326,7 +326,7 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
if rctx.SidebarTocNode != nil {
sb := &strings.Builder{}
- err = markdown.SpecializedMarkdown().Renderer().Render(sb, nil, rctx.SidebarTocNode)
+ err = markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode)
if err != nil {
log.Error("Failed to render wiki sidebar TOC: %v", err)
} else {
diff --git a/services/context/repo.go b/services/context/repo.go
index e7b32d62832d4..1eafb7ca48bb4 100644
--- a/services/context/repo.go
+++ b/services/context/repo.go
@@ -393,14 +393,7 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
}
}
- pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{})
- if err != nil {
- ctx.ServerError("GetPushMirrorsByRepoID", err)
- return
- }
-
ctx.Repo.Repository = repo
- ctx.Data["PushMirrors"] = pushMirrors
ctx.Data["RepoName"] = ctx.Repo.Repository.Name
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index d27bbca894811..8e663084f86e1 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -122,7 +122,7 @@ type RepoSettingForm struct {
MirrorPassword string
LFS bool `form:"mirror_lfs"`
LFSEndpoint string `form:"mirror_lfs_endpoint"`
- PushMirrorID string
+ PushMirrorID int64
PushMirrorAddress string
PushMirrorUsername string
PushMirrorPassword string
diff --git a/services/mirror/mirror.go b/services/mirror/mirror.go
index 44218d6fb3f5d..e029bbb1d6353 100644
--- a/services/mirror/mirror.go
+++ b/services/mirror/mirror.go
@@ -8,7 +8,6 @@ import (
"fmt"
repo_model "code.gitea.io/gitea/models/repo"
- "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
@@ -119,14 +118,7 @@ func Update(ctx context.Context, pullLimit, pushLimit int) error {
return nil
}
-func queueHandler(items ...*SyncRequest) []*SyncRequest {
- for _, req := range items {
- doMirrorSync(graceful.GetManager().ShutdownContext(), req)
- }
- return nil
-}
-
// InitSyncMirrors initializes a go routine to sync the mirrors
func InitSyncMirrors() {
- StartSyncMirrors(queueHandler)
+ StartSyncMirrors()
}
diff --git a/services/mirror/queue.go b/services/mirror/queue.go
index 0d9a624730daa..ca5e2c7272a50 100644
--- a/services/mirror/queue.go
+++ b/services/mirror/queue.go
@@ -28,12 +28,19 @@ type SyncRequest struct {
ReferenceID int64 // RepoID for pull mirror, MirrorID for push mirror
}
+func queueHandler(items ...*SyncRequest) []*SyncRequest {
+ for _, req := range items {
+ doMirrorSync(graceful.GetManager().ShutdownContext(), req)
+ }
+ return nil
+}
+
// StartSyncMirrors starts a go routine to sync the mirrors
-func StartSyncMirrors(queueHandle func(data ...*SyncRequest) []*SyncRequest) {
+func StartSyncMirrors() {
if !setting.Mirror.Enabled {
return
}
- mirrorQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "mirror", queueHandle)
+ mirrorQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "mirror", queueHandler)
if mirrorQueue == nil {
log.Fatal("Unable to create mirror queue")
}
diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go
index f79afa4b301ba..dd3f24eeef242 100644
--- a/services/oauth2_provider/access_token.go
+++ b/services/oauth2_provider/access_token.go
@@ -148,7 +148,7 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
Nonce: grant.Nonce,
}
if grant.ScopeContains("profile") {
- idToken.Name = user.GetDisplayName()
+ idToken.Name = user.DisplayName()
idToken.PreferredUsername = user.Name
idToken.Profile = user.HTMLURL()
idToken.Picture = user.AvatarLink(ctx)
diff --git a/services/repository/fork.go b/services/repository/fork.go
index 5b24015a0384f..bc4fdf85627b0 100644
--- a/services/repository/fork.go
+++ b/services/repository/fork.go
@@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
@@ -20,6 +21,8 @@ import (
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
+
+ "xorm.io/builder"
)
// ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error.
@@ -247,3 +250,24 @@ func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Reposit
return err
}
+
+type findForksOptions struct {
+ db.ListOptions
+ RepoID int64
+ Doer *user_model.User
+}
+
+func (opts findForksOptions) ToConds() builder.Cond {
+ return builder.Eq{"fork_id": opts.RepoID}.And(
+ repo_model.AccessibleRepositoryCondition(opts.Doer, unit.TypeInvalid),
+ )
+}
+
+// FindForks returns all the forks of the repository
+func FindForks(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, listOptions db.ListOptions) ([]*repo_model.Repository, int64, error) {
+ return db.FindAndCount[repo_model.Repository](ctx, findForksOptions{
+ ListOptions: listOptions,
+ RepoID: repo.ID,
+ Doer: doer,
+ })
+}
diff --git a/templates/admin/org/list.tmpl b/templates/admin/org/list.tmpl
index d0805c85bc9d1..d5e09939c5324 100644
--- a/templates/admin/org/list.tmpl
+++ b/templates/admin/org/list.tmpl
@@ -52,7 +52,7 @@
{{.ID}}
- {{.Name}}
+ {{if and DefaultShowFullName .FullName}}{{.FullName}} ({{.Name}}){{else}}{{.Name}}{{end}}
{{if .Visibility.IsPrivate}}
{{svg "octicon-lock"}}
{{end}}
diff --git a/templates/install.tmpl b/templates/install.tmpl
index 5055031a9029c..ea4023d409098 100644
--- a/templates/install.tmpl
+++ b/templates/install.tmpl
@@ -338,7 +338,7 @@
- These configuration options will be written into: {{.CustomConfFile}}
+ {{ctx.Locale.Tr "install.config_write_file_prompt"}} {{.CustomConfFile}}
diff --git a/templates/repo/forks.tmpl b/templates/repo/forks.tmpl
index 412c59b60e84d..725b67c651cb1 100644
--- a/templates/repo/forks.tmpl
+++ b/templates/repo/forks.tmpl
@@ -5,12 +5,14 @@
{{ctx.Locale.Tr "repo.forks"}}
+
{{range .Forks}}
-
- {{ctx.AvatarUtils.Avatar .Owner}}
- {{.Owner.Name}} / {{.Name}}
+
+ {{ctx.AvatarUtils.Avatar .Owner}}
+ {{.Owner.Name}} / {{.Name}}
{{end}}
+
{{template "base/paginate" .}}
diff --git a/templates/user/dashboard/repolist.tmpl b/templates/user/dashboard/repolist.tmpl
index be710675d560a..a2764ba608442 100644
--- a/templates/user/dashboard/repolist.tmpl
+++ b/templates/user/dashboard/repolist.tmpl
@@ -45,7 +45,7 @@ data.teamId = {{.Team.ID}};
{{end}}
{{if not .ContextUser.IsOrganization}}
-data.organizations = [{{range .Orgs}}{'name': {{.Name}}, 'num_repos': {{.NumRepos}}, 'org_visibility': {{.Visibility}}},{{end}}];
+data.organizations = [{{range .Orgs}}{'name': {{.Name}}, 'full_name': {{.FullName}}, 'num_repos': {{.NumRepos}}, 'org_visibility': {{.Visibility}}},{{end}}];
data.isOrganization = false;
data.organizationsTotalCount = {{.UserOrgsCount}};
data.canCreateOrganization = {{.SignedUser.CanCreateOrganization}};
diff --git a/tests/integration/api_fork_test.go b/tests/integration/api_fork_test.go
index 7c231415a318a..357dd27f86888 100644
--- a/tests/integration/api_fork_test.go
+++ b/tests/integration/api_fork_test.go
@@ -7,8 +7,16 @@ import (
"net/http"
"testing"
+ "code.gitea.io/gitea/models"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ org_model "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
)
func TestCreateForkNoLogin(t *testing.T) {
@@ -16,3 +24,75 @@ func TestCreateForkNoLogin(t *testing.T) {
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{})
MakeRequest(t, req, http.StatusUnauthorized)
}
+
+func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user1Sess := loginUser(t, "user1")
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+
+ // fork into a limited org
+ limitedOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
+ assert.EqualValues(t, api.VisibleTypeLimited, limitedOrg.Visibility)
+
+ ownerTeam1, err := org_model.OrgFromUser(limitedOrg).GetOwnerTeam(db.DefaultContext)
+ assert.NoError(t, err)
+ assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam1, user1))
+ user1Token := getTokenForLoggedInUser(t, user1Sess, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{
+ Organization: &limitedOrg.Name,
+ }).AddTokenAuth(user1Token)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ // fork into a private org
+ user4Sess := loginUser(t, "user4")
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user4"})
+ privateOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23})
+ assert.EqualValues(t, api.VisibleTypePrivate, privateOrg.Visibility)
+
+ ownerTeam2, err := org_model.OrgFromUser(privateOrg).GetOwnerTeam(db.DefaultContext)
+ assert.NoError(t, err)
+ assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user4))
+ user4Token := getTokenForLoggedInUser(t, user4Sess, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
+ req = NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{
+ Organization: &privateOrg.Name,
+ }).AddTokenAuth(user4Token)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ t.Run("Anonymous", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var forks []*api.Repository
+ DecodeJSON(t, resp, &forks)
+
+ assert.Empty(t, forks)
+ assert.EqualValues(t, "0", resp.Header().Get("X-Total-Count"))
+ })
+
+ t.Run("Logged in", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var forks []*api.Repository
+ DecodeJSON(t, resp, &forks)
+
+ assert.Len(t, forks, 1)
+ assert.EqualValues(t, "1", resp.Header().Get("X-Total-Count"))
+
+ assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user1))
+
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ forks = []*api.Repository{}
+ DecodeJSON(t, resp, &forks)
+
+ assert.Len(t, forks, 2)
+ assert.EqualValues(t, "2", resp.Header().Get("X-Total-Count"))
+ })
+}
diff --git a/tests/integration/db_collation_test.go b/tests/integration/db_collation_test.go
index 75a4c1594fd82..acec4aa5d1e85 100644
--- a/tests/integration/db_collation_test.go
+++ b/tests/integration/db_collation_test.go
@@ -73,9 +73,12 @@ func TestDatabaseCollation(t *testing.T) {
t.Run("Convert tables to utf8mb4_bin", func(t *testing.T) {
defer test.MockVariableValue(&setting.Database.CharsetCollation, "utf8mb4_bin")()
- assert.NoError(t, db.ConvertDatabaseTable())
r, err := db.CheckCollations(x)
assert.NoError(t, err)
+ assert.EqualValues(t, "utf8mb4_bin", r.ExpectedCollation)
+ assert.NoError(t, db.ConvertDatabaseTable())
+ r, err = db.CheckCollations(x)
+ assert.NoError(t, err)
assert.Equal(t, "utf8mb4_bin", r.DatabaseCollation)
assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation))
assert.Empty(t, r.InconsistentCollationColumns)
diff --git a/tests/integration/mirror_push_test.go b/tests/integration/mirror_push_test.go
index 6b1c808cf46ba..9ff4669befecf 100644
--- a/tests/integration/mirror_push_test.go
+++ b/tests/integration/mirror_push_test.go
@@ -9,7 +9,9 @@ import (
"net/http"
"net/url"
"strconv"
+ "strings"
"testing"
+ "time"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
@@ -32,11 +34,10 @@ func TestMirrorPush(t *testing.T) {
}
func testMirrorPush(t *testing.T, u *url.URL) {
- defer tests.PrepareTestEnv(t)()
-
setting.Migrations.AllowLocalNetworks = true
assert.NoError(t, migrations.Init())
+ _ = db.TruncateBeans(db.DefaultContext, &repo_model.PushMirror{})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
@@ -45,9 +46,10 @@ func testMirrorPush(t *testing.T, u *url.URL) {
})
assert.NoError(t, err)
- ctx := NewAPITestContext(t, user.LowerName, srcRepo.Name)
+ session := loginUser(t, user.Name)
- doCreatePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape(mirrorRepo.Name)), user.LowerName, userPassword)(t)
+ pushMirrorURL := fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(user.Name), url.PathEscape(mirrorRepo.Name))
+ testCreatePushMirror(t, session, user.Name, srcRepo.Name, pushMirrorURL, user.LowerName, userPassword, "0")
mirrors, _, err := repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{})
assert.NoError(t, err)
@@ -73,49 +75,81 @@ func testMirrorPush(t *testing.T, u *url.URL) {
assert.Equal(t, srcCommit.ID, mirrorCommit.ID)
// Cleanup
- doRemovePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape(mirrorRepo.Name)), user.LowerName, userPassword, int(mirrors[0].ID))(t)
+ assert.True(t, doRemovePushMirror(t, session, user.Name, srcRepo.Name, mirrors[0].ID))
mirrors, _, err = repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{})
assert.NoError(t, err)
assert.Len(t, mirrors, 0)
}
-func doCreatePushMirror(ctx APITestContext, address, username, password string) func(t *testing.T) {
- return func(t *testing.T) {
- csrf := GetUserCSRFToken(t, ctx.Session)
-
- req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{
- "_csrf": csrf,
- "action": "push-mirror-add",
- "push_mirror_address": address,
- "push_mirror_username": username,
- "push_mirror_password": password,
- "push_mirror_interval": "0",
- })
- ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
-
- flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash)
- assert.NotNil(t, flashCookie)
- assert.Contains(t, flashCookie.Value, "success")
- }
+func testCreatePushMirror(t *testing.T, session *TestSession, owner, repo, address, username, password, interval string) {
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(owner), url.PathEscape(repo)), map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "action": "push-mirror-add",
+ "push_mirror_address": address,
+ "push_mirror_username": username,
+ "push_mirror_password": password,
+ "push_mirror_interval": interval,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success")
+}
+
+func doRemovePushMirror(t *testing.T, session *TestSession, owner, repo string, pushMirrorID int64) bool {
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(owner), url.PathEscape(repo)), map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "action": "push-mirror-remove",
+ "push_mirror_id": strconv.FormatInt(pushMirrorID, 10),
+ })
+ resp := session.MakeRequest(t, req, NoExpectedStatus)
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ return resp.Code == http.StatusSeeOther && flashCookie != nil && strings.Contains(flashCookie.Value, "success")
+}
+
+func doUpdatePushMirror(t *testing.T, session *TestSession, owner, repo string, pushMirrorID int64, interval string) bool {
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", owner, repo), map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "action": "push-mirror-update",
+ "push_mirror_id": strconv.FormatInt(pushMirrorID, 10),
+ "push_mirror_interval": interval,
+ "push_mirror_defer_sync": "true",
+ })
+ resp := session.MakeRequest(t, req, NoExpectedStatus)
+ return resp.Code == http.StatusSeeOther
}
-func doRemovePushMirror(ctx APITestContext, address, username, password string, pushMirrorID int) func(t *testing.T) {
- return func(t *testing.T) {
- csrf := GetUserCSRFToken(t, ctx.Session)
-
- req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{
- "_csrf": csrf,
- "action": "push-mirror-remove",
- "push_mirror_id": strconv.Itoa(pushMirrorID),
- "push_mirror_address": address,
- "push_mirror_username": username,
- "push_mirror_password": password,
- "push_mirror_interval": "0",
- })
- ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
-
- flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash)
- assert.NotNil(t, flashCookie)
- assert.Contains(t, flashCookie.Value, "success")
- }
+func TestRepoSettingPushMirrorUpdate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ setting.Migrations.AllowLocalNetworks = true
+ assert.NoError(t, migrations.Init())
+
+ session := loginUser(t, "user2")
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ testCreatePushMirror(t, session, "user2", "repo2", "https://127.0.0.1/user1/repo1.git", "", "", "24h")
+
+ pushMirrors, cnt, err := repo_model.GetPushMirrorsByRepoID(db.DefaultContext, repo2.ID, db.ListOptions{})
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, cnt)
+ assert.EqualValues(t, 24*time.Hour, pushMirrors[0].Interval)
+ repo2PushMirrorID := pushMirrors[0].ID
+
+ // update repo2 push mirror
+ assert.True(t, doUpdatePushMirror(t, session, "user2", "repo2", repo2PushMirrorID, "10m0s"))
+ pushMirror := unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{ID: repo2PushMirrorID})
+ assert.EqualValues(t, 10*time.Minute, pushMirror.Interval)
+
+ // avoid updating repo2 push mirror from repo1
+ assert.False(t, doUpdatePushMirror(t, session, "user2", "repo1", repo2PushMirrorID, "20m0s"))
+ pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{ID: repo2PushMirrorID})
+ assert.EqualValues(t, 10*time.Minute, pushMirror.Interval) // not changed
+
+ // avoid deleting repo2 push mirror from repo1
+ assert.False(t, doRemovePushMirror(t, session, "user2", "repo1", repo2PushMirrorID))
+ unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{ID: repo2PushMirrorID})
+
+ // delete repo2 push mirror
+ assert.True(t, doRemovePushMirror(t, session, "user2", "repo2", repo2PushMirrorID))
+ unittest.AssertNotExistsBean(t, &repo_model.PushMirror{ID: repo2PushMirrorID})
}
diff --git a/tests/integration/repo_fork_test.go b/tests/integration/repo_fork_test.go
index feebebf062081..52b55888b9dd9 100644
--- a/tests/integration/repo_fork_test.go
+++ b/tests/integration/repo_fork_test.go
@@ -9,8 +9,12 @@ import (
"net/http/httptest"
"testing"
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@@ -74,3 +78,51 @@ func TestRepoForkToOrg(t *testing.T) {
_, exists := htmlDoc.doc.Find(`a.ui.button[href*="/fork"]`).Attr("href")
assert.False(t, exists, "Forking should not be allowed anymore")
}
+
+func TestForkListLimitedAndPrivateRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ forkItemSelector := ".repo-fork-item"
+
+ user1Sess := loginUser(t, "user1")
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+
+ // fork to a limited org
+ limitedOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
+ assert.EqualValues(t, structs.VisibleTypeLimited, limitedOrg.Visibility)
+ ownerTeam1, err := org_model.OrgFromUser(limitedOrg).GetOwnerTeam(db.DefaultContext)
+ assert.NoError(t, err)
+ assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam1, user1))
+ testRepoFork(t, user1Sess, "user2", "repo1", limitedOrg.Name, "repo1", "")
+
+ // fork to a private org
+ user4Sess := loginUser(t, "user4")
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user4"})
+ privateOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23})
+ assert.EqualValues(t, structs.VisibleTypePrivate, privateOrg.Visibility)
+ ownerTeam2, err := org_model.OrgFromUser(privateOrg).GetOwnerTeam(db.DefaultContext)
+ assert.NoError(t, err)
+ assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user4))
+ testRepoFork(t, user4Sess, "user2", "repo1", privateOrg.Name, "repo1", "")
+
+ t.Run("Anonymous", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", "/user2/repo1/forks")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.EqualValues(t, 0, htmlDoc.Find(forkItemSelector).Length())
+ })
+
+ t.Run("Logged in", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/forks")
+ resp := user1Sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.EqualValues(t, 1, htmlDoc.Find(forkItemSelector).Length())
+
+ assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user1))
+ resp = user1Sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assert.EqualValues(t, 2, htmlDoc.Find(forkItemSelector).Length())
+ })
+}
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
index a6a8ccd2d11e1..3d3ac2fc69716 100644
--- a/web_src/js/components/DashboardRepoList.vue
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -471,7 +471,7 @@ export default sfc; // activate the IDE's Vue plugin
- {{ org.name }}
+ {{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}
{{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}