mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Refactor markup render system (#32533)
Remove unmaintainable sanitizer rules. No need to add special "class" regexp rules anymore, use RenderInternal.SafeAttr instead, more details (and examples) are in the tests
This commit is contained in:
		| @@ -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 | ||||
| } | ||||
							
								
								
									
										48
									
								
								modules/htmlutil/html.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								modules/htmlutil/html.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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...)) | ||||
| } | ||||
							
								
								
									
										15
									
								
								modules/htmlutil/html_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								modules/htmlutil/html_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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("<a>< < 1</a>"), HTMLFormat("<a>%s %s %d</a>", "<", template.HTML("<"), 1)) | ||||
| } | ||||
| @@ -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( | ||||
| 		`<div class="%s" %s="%s"></div>`, | ||||
| 		playerClassName, | ||||
| 		playerSrcAttr, | ||||
| 		rawURL, | ||||
| 	)) | ||||
| 	return err | ||||
| 	return ctx.RenderInternal.FormatWithSafeAttrs(output, `<div class="%s" %s="%s"></div>`, playerClassName, playerSrcAttr, rawURL) | ||||
| } | ||||
|   | ||||
| @@ -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?://") | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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') | ||||
|   | ||||
| @@ -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 | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										9
									
								
								modules/markup/external/external.go
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								modules/markup/external/external.go
									
									
									
									
										vendored
									
									
								
							| @@ -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() | ||||
|   | ||||
| @@ -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 `<span class="emoji">`, 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{ | ||||
|   | ||||
| @@ -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: "<p>{TextBefore}</p><div NewNode/><p>{TextAfter}</p>", | ||||
| 		//    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) | ||||
| 		} | ||||
|   | ||||
| @@ -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 | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| 		} | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
| 		} | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
							
								
								
									
										30
									
								
								modules/markup/internal/finalprocessor.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								modules/markup/internal/finalprocessor.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										61
									
								
								modules/markup/internal/internal_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								modules/markup/internal/internal_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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:     `<div class="test">class="content"</div>`, | ||||
| 			protected: `<div data-attr-class="sec:test">class="content"</div>`, | ||||
| 			recovered: `<div class="test">class="content"</div>`, | ||||
| 		}, | ||||
| 		{ | ||||
| 			input:     "<div\nclass=\"test\" data-xxx></div>", | ||||
| 			protected: `<div data-attr-class="sec:test" data-xxx></div>`, | ||||
| 			recovered: `<div class="test" data-xxx></div>`, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, c := range cases { | ||||
| 		var r RenderInternal | ||||
| 		out := &bytes.Buffer{} | ||||
| 		in := r.init("sec", out) | ||||
| 		protected := r.ProtectSafeAttrs(template.HTML(c.input)) | ||||
| 		assert.EqualValues(t, c.protected, protected) | ||||
| 		_, _ = io.WriteString(in, string(protected)) | ||||
| 		_ = in.Close() | ||||
| 		assert.EqualValues(t, c.recovered, out.String()) | ||||
| 	} | ||||
|  | ||||
| 	var r1, r2 RenderInternal | ||||
| 	protected := r1.ProtectSafeAttrs(`<div class="test"></div>`) | ||||
| 	assert.EqualValues(t, `<div class="test"></div>`, protected, "non-initialized RenderInternal should not protect any attributes") | ||||
| 	_ = r1.init("sec", nil) | ||||
| 	protected = r1.ProtectSafeAttrs(`<div class="test"></div>`) | ||||
| 	assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected) | ||||
| 	assert.EqualValues(t, "data-attr-class", r1.SafeAttr("class")) | ||||
| 	assert.EqualValues(t, "sec:val", r1.SafeValue("val")) | ||||
| 	recovered, ok := r1.RecoverProtectedValue("sec:val") | ||||
| 	assert.True(t, ok) | ||||
| 	assert.EqualValues(t, "val", recovered) | ||||
| 	recovered, ok = r1.RecoverProtectedValue("other:val") | ||||
| 	assert.False(t, ok) | ||||
| 	assert.Empty(t, recovered) | ||||
|  | ||||
| 	out2 := &bytes.Buffer{} | ||||
| 	in2 := r2.init("sec-other", out2) | ||||
| 	_, _ = io.WriteString(in2, string(protected)) | ||||
| 	_ = in2.Close() | ||||
| 	assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, out2.String(), "different secureID should not recover the value") | ||||
| } | ||||
							
								
								
									
										82
									
								
								modules/markup/internal/renderinternal.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								modules/markup/internal/renderinternal.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| // Copyright 2024 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"crypto/rand" | ||||
| 	"encoding/base64" | ||||
| 	"html/template" | ||||
| 	"io" | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/htmlutil" | ||||
|  | ||||
| 	"golang.org/x/net/html" | ||||
| ) | ||||
|  | ||||
| var reAttrClass = sync.OnceValue[*regexp.Regexp](func() *regexp.Regexp { | ||||
| 	// TODO: it isn't a problem at the moment because our HTML contents are always well constructed | ||||
| 	return regexp.MustCompile(`(<[^>]+)\s+class="([^"]+)"([^>]*>)`) | ||||
| }) | ||||
|  | ||||
| // RenderInternal also works without initialization | ||||
| // If no initialization (no secureID), it will not protect any attributes and return the original name&value | ||||
| type RenderInternal struct { | ||||
| 	secureID       string | ||||
| 	secureIDPrefix string | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) Init(output io.Writer) io.WriteCloser { | ||||
| 	buf := make([]byte, 12) | ||||
| 	_, err := rand.Read(buf) | ||||
| 	if err != nil { | ||||
| 		panic("unable to generate secure id") | ||||
| 	} | ||||
| 	return r.init(base64.URLEncoding.EncodeToString(buf), output) | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) init(secID string, output io.Writer) io.WriteCloser { | ||||
| 	r.secureID = secID | ||||
| 	r.secureIDPrefix = r.secureID + ":" | ||||
| 	return &finalProcessor{renderInternal: r, output: output} | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) { | ||||
| 	if !strings.HasPrefix(v, r.secureIDPrefix) { | ||||
| 		return "", false | ||||
| 	} | ||||
| 	return v[len(r.secureIDPrefix):], true | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) SafeAttr(name string) string { | ||||
| 	if r.secureID == "" { | ||||
| 		return name | ||||
| 	} | ||||
| 	return "data-attr-" + name | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) SafeValue(val string) string { | ||||
| 	if r.secureID == "" { | ||||
| 		return val | ||||
| 	} | ||||
| 	return r.secureID + ":" + val | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) NodeSafeAttr(attr, val string) html.Attribute { | ||||
| 	return html.Attribute{Key: r.SafeAttr(attr), Val: r.SafeValue(val)} | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML { | ||||
| 	if r.secureID == "" { | ||||
| 		return content | ||||
| 	} | ||||
| 	return template.HTML(reAttrClass().ReplaceAllString(string(content), `$1 data-attr-class="`+r.secureIDPrefix+`$2"$3`)) | ||||
| } | ||||
|  | ||||
| func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt string, a ...any) error { | ||||
| 	_, err := w.Write([]byte(r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...)))) | ||||
| 	return err | ||||
| } | ||||
| @@ -34,13 +34,6 @@ func NewDetails() *Details { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // IsDetails returns true if the given node implements the Details interface, | ||||
| // otherwise false. | ||||
| func IsDetails(node ast.Node) bool { | ||||
| 	_, ok := node.(*Details) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| // Summary is a block that contains the summary of details block | ||||
| type Summary struct { | ||||
| 	ast.BaseBlock | ||||
| @@ -66,13 +59,6 @@ func NewSummary() *Summary { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // IsSummary returns true if the given node implements the Summary interface, | ||||
| // otherwise false. | ||||
| func IsSummary(node ast.Node) bool { | ||||
| 	_, ok := node.(*Summary) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| // TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox | ||||
| type TaskCheckBoxListItem struct { | ||||
| 	*ast.ListItem | ||||
| @@ -103,14 +89,7 @@ func NewTaskCheckBoxListItem(listItem *ast.ListItem) *TaskCheckBoxListItem { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // IsTaskCheckBoxListItem returns true if the given node implements the TaskCheckBoxListItem interface, | ||||
| // otherwise false. | ||||
| func IsTaskCheckBoxListItem(node ast.Node) bool { | ||||
| 	_, ok := node.(*TaskCheckBoxListItem) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| // Icon is an inline for a fomantic icon | ||||
| // Icon is an inline for a Fomantic UI icon | ||||
| type Icon struct { | ||||
| 	ast.BaseInline | ||||
| 	Name []byte | ||||
| @@ -139,13 +118,6 @@ func NewIcon(name string) *Icon { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // IsIcon returns true if the given node implements the Icon interface, | ||||
| // otherwise false. | ||||
| func IsIcon(node ast.Node) bool { | ||||
| 	_, ok := node.(*Icon) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| // ColorPreview is an inline for a color preview | ||||
| type ColorPreview struct { | ||||
| 	ast.BaseInline | ||||
|   | ||||
| @@ -7,9 +7,11 @@ import ( | ||||
| 	"fmt" | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/container" | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| 	"code.gitea.io/gitea/modules/markup/internal" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
|  | ||||
| 	"github.com/yuin/goldmark/ast" | ||||
| @@ -23,11 +25,13 @@ import ( | ||||
|  | ||||
| // ASTTransformer is a default transformer of the goldmark tree. | ||||
| type ASTTransformer struct { | ||||
| 	renderInternal *internal.RenderInternal | ||||
| 	attentionTypes container.Set[string] | ||||
| } | ||||
|  | ||||
| func NewASTTransformer() *ASTTransformer { | ||||
| func NewASTTransformer(renderInternal *internal.RenderInternal) *ASTTransformer { | ||||
| 	return &ASTTransformer{ | ||||
| 		renderInternal: renderInternal, | ||||
| 		attentionTypes: container.SetOf("note", "tip", "important", "warning", "caution"), | ||||
| 	} | ||||
| } | ||||
| @@ -109,12 +113,16 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // NewHTMLRenderer creates a HTMLRenderer to render | ||||
| // in the gitea form. | ||||
| func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { | ||||
| // it is copied from old code, which is quite doubtful whether it is correct | ||||
| var reValidIconName = sync.OnceValue[*regexp.Regexp](func() *regexp.Regexp { | ||||
| 	return regexp.MustCompile(`^[-\w]+$`) // old: regexp.MustCompile("^[a-z ]+$") | ||||
| }) | ||||
|  | ||||
| // NewHTMLRenderer creates a HTMLRenderer to render in the gitea form. | ||||
| func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer { | ||||
| 	r := &HTMLRenderer{ | ||||
| 		renderInternal: renderInternal, | ||||
| 		Config:         html.NewConfig(), | ||||
| 		reValidName: regexp.MustCompile("^[a-z ]+$"), | ||||
| 	} | ||||
| 	for _, opt := range opts { | ||||
| 		opt.SetHTMLOption(&r.Config) | ||||
| @@ -126,7 +134,7 @@ func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { | ||||
| // renders gitea specific features. | ||||
| type HTMLRenderer struct { | ||||
| 	html.Config | ||||
| 	reValidName *regexp.Regexp | ||||
| 	renderInternal *internal.RenderInternal | ||||
| } | ||||
|  | ||||
| // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. | ||||
| @@ -214,12 +222,13 @@ func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node | ||||
| 		return ast.WalkContinue, nil | ||||
| 	} | ||||
|  | ||||
| 	if !r.reValidName.MatchString(name) { | ||||
| 	if !reValidIconName().MatchString(name) { | ||||
| 		// skip this | ||||
| 		return ast.WalkContinue, nil | ||||
| 	} | ||||
|  | ||||
| 	_, err := w.WriteString(fmt.Sprintf(`<i class="icon %s"></i>`, name)) | ||||
| 	// FIXME: the "icon xxx" is from Fomantic UI, it's really questionable whether it still works correctly | ||||
| 	err := r.renderInternal.FormatWithSafeAttrs(w, `<i class="icon %s"></i>`, name) | ||||
| 	if err != nil { | ||||
| 		return ast.WalkStop, err | ||||
| 	} | ||||
|   | ||||
| @@ -9,7 +9,6 @@ import ( | ||||
| 	"html/template" | ||||
| 	"io" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| @@ -29,11 +28,6 @@ import ( | ||||
| 	"github.com/yuin/goldmark/util" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	specMarkdown     goldmark.Markdown | ||||
| 	specMarkdownOnce sync.Once | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	renderContextKey = parser.NewContextKey() | ||||
| 	renderConfigKey  = parser.NewContextKey() | ||||
| @@ -68,23 +62,21 @@ func newParserContext(ctx *markup.RenderContext) parser.Context { | ||||
| 	return pc | ||||
| } | ||||
|  | ||||
| // 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) { | ||||
| type GlodmarkRender struct { | ||||
| 	ctx *markup.RenderContext | ||||
|  | ||||
| 	goldmarkMarkdown goldmark.Markdown | ||||
| } | ||||
|  | ||||
| func (r *GlodmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error { | ||||
| 	return r.goldmarkMarkdown.Convert(source, writer, opts...) | ||||
| } | ||||
|  | ||||
| func (r *GlodmarkRender) Renderer() renderer.Renderer { | ||||
| 	return r.goldmarkMarkdown.Renderer() | ||||
| } | ||||
|  | ||||
| func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) { | ||||
| 	if entering { | ||||
| 		language, _ := c.Language() | ||||
| 		if language == nil { | ||||
| @@ -98,14 +90,14 @@ func SpecializedMarkdown() goldmark.Markdown { | ||||
| 			preClasses = append(preClasses, "is-loading") | ||||
| 		} | ||||
|  | ||||
| 							_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`) | ||||
| 		err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<pre class="%s">`, 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 = w.WriteString(`<code class="chroma language-` + string(language) + ` display">`) | ||||
| 		err = r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<code class="chroma language-%s display">`, string(language)) | ||||
| 		if err != nil { | ||||
| 			return | ||||
| 		} | ||||
| @@ -115,38 +107,50 @@ func SpecializedMarkdown() goldmark.Markdown { | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 					}), | ||||
| } | ||||
|  | ||||
| // SpecializedMarkdown sets up the Gitea specific markdown extensions | ||||
| 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), | ||||
| 				), | ||||
| 				math.NewExtension( | ||||
| 					math.Enabled(setting.Markdown.EnableMath), | ||||
| 				highlighting.WithWrapperRenderer(r.highlightingRenderer), | ||||
| 			), | ||||
| 			math.NewExtension(&ctx.RenderInternal, math.Enabled(setting.Markdown.EnableMath)), | ||||
| 			meta.Meta, | ||||
| 		), | ||||
| 		goldmark.WithParserOptions( | ||||
| 			parser.WithAttribute(), | ||||
| 			parser.WithAutoHeadingID(), | ||||
| 				parser.WithASTTransformers( | ||||
| 					util.Prioritized(NewASTTransformer(), 10000), | ||||
| 				), | ||||
| 			), | ||||
| 			goldmark.WithRendererOptions( | ||||
| 				html.WithUnsafe(), | ||||
| 			parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)), | ||||
| 		), | ||||
| 		goldmark.WithRendererOptions(html.WithUnsafe()), | ||||
| 	) | ||||
|  | ||||
| 	// Override the original Tasklist renderer! | ||||
| 		specMarkdown.Renderer().AddOptions( | ||||
| 			renderer.WithNodeRenderers( | ||||
| 				util.Prioritized(NewHTMLRenderer(), 10), | ||||
| 			), | ||||
| 	r.goldmarkMarkdown.Renderer().AddOptions( | ||||
| 		renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)), | ||||
| 	) | ||||
| 	}) | ||||
| 	return specMarkdown | ||||
|  | ||||
| 	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" | ||||
|  | ||||
|   | ||||
| @@ -1051,3 +1051,17 @@ func TestAttention(t *testing.T) { | ||||
| 	// legacy GitHub style | ||||
| 	test(`> **warning**`, renderAttention("warning", "octicon-alert")+"\n</blockquote>") | ||||
| } | ||||
|  | ||||
| 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") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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(`<pre class="code-block is-loading"><code class="chroma language-math display">`) | ||||
| 		_ = r.renderInternal.FormatWithSafeAttrs(w, `<pre class="code-block is-loading"><code class="chroma language-math display">`) | ||||
| 		r.writeLines(w, source, n) | ||||
| 	} else { | ||||
| 		_, _ = w.WriteString(`</code></pre>` + "\n") | ||||
|   | ||||
| @@ -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(`<code class="language-math ` + extraClass + `is-loading">`) | ||||
| 		_ = r.renderInternal.FormatWithSafeAttrs(w, `<code class="language-math %sis-loading">`, extraClass) | ||||
| 		for c := n.FirstChild(); c != nil; c = c.NextSibling() { | ||||
| 			segment := c.(*ast.Text).Segment | ||||
| 			value := util.EscapeHTML(segment.Value(source)) | ||||
|   | ||||
| @@ -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), | ||||
| 	)) | ||||
| } | ||||
|   | ||||
| @@ -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"` | ||||
|   | ||||
| @@ -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))) | ||||
|  | ||||
|   | ||||
| @@ -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(`<span class="color-preview" style="background-color: %v"></span>`, string(v.Color))) | ||||
| 				_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, string(v.Color)) | ||||
| 			} | ||||
| 		} | ||||
| 		return ast.WalkSkipChildren, nil | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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() | ||||
| 	}() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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 pw2 io.WriteCloser | ||||
|  | ||||
| 	var sanitizerDisabled bool | ||||
| 	if r, ok := renderer.(ExternalRenderer); ok { | ||||
| 		sanitizerDisabled = r.SanitizerDisabled() | ||||
| 		var close2 func() | ||||
| 		pr2, pw2, close2 = pipes() | ||||
| 		defer close2() | ||||
| 		eg.Go(func() error { | ||||
| 			defer pr2.Close() | ||||
| 			return SanitizeReader(pr2, renderer.Name(), finalProcessor) | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	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} | ||||
| 	} | ||||
|  | ||||
| 	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() | ||||
| 	}() | ||||
|  | ||||
| 	if err1 := renderer.Render(ctx, input, pw); err1 != nil { | ||||
| 		return err1 | ||||
| 	} | ||||
| 	_ = pw.Close() | ||||
|  | ||||
| 	wg.Wait() | ||||
| 		_, _ = pr1.Close(), pw2.Close() | ||||
| 		return err | ||||
| 	}) | ||||
|  | ||||
| 	if err := renderer.Render(ctx, input, pw1); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_ = pw1.Close() | ||||
|  | ||||
| 	return eg.Wait() | ||||
| } | ||||
|  | ||||
| // Init initializes the render global variables | ||||
|   | ||||
| @@ -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) | ||||
| 			} | ||||
|   | ||||
| @@ -16,37 +16,12 @@ 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") | ||||
| 	// NOTICE: DO NOT add special "class" regexp rules here anymore, use RenderInternal.SafeAttr instead | ||||
|  | ||||
| 	// 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") | ||||
| 	// General safe SVG attributes | ||||
| 	policy.AllowAttrs("viewBox", "width", "height", "aria-hidden", "data-attr-class").OnElements("svg") | ||||
| 	policy.AllowAttrs("fill-rule", "d").OnElements("path") | ||||
|  | ||||
| 	// For Chroma markdown plugin | ||||
| 	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code") | ||||
|  | ||||
| 	// Checkboxes | ||||
| 	policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") | ||||
| 	policy.AllowAttrs("checked", "disabled", "data-source-position").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) | ||||
|   | ||||
| @@ -19,7 +19,6 @@ func TestSanitizer(t *testing.T) { | ||||
| 		// Code highlighting class | ||||
| 		`<code class="random string"></code>`, `<code></code>`, | ||||
| 		`<code class="language-random ui tab active menu attached animating sidebar following bar center"></code>`, `<code></code>`, | ||||
| 		`<code class="language-go"></code>`, `<code class="language-go"></code>`, | ||||
|  | ||||
| 		// Input checkbox | ||||
| 		`<input type="hidden">`, ``, | ||||
| @@ -38,10 +37,8 @@ func TestSanitizer(t *testing.T) { | ||||
| 		// <kbd> tags | ||||
| 		`<kbd>Ctrl + C</kbd>`, `<kbd>Ctrl + C</kbd>`, | ||||
| 		`<i class="dropdown icon">NAUGHTY</i>`, `<i>NAUGHTY</i>`, | ||||
| 		`<i class="icon dropdown"></i>`, `<i class="icon dropdown"></i>`, | ||||
| 		`<input type="checkbox" disabled=""/>unchecked`, `<input type="checkbox" disabled=""/>unchecked`, | ||||
| 		`<span class="emoji dropdown">NAUGHTY</span>`, `<span>NAUGHTY</span>`, | ||||
| 		`<span class="emoji">contents</span>`, `<span class="emoji">contents</span>`, | ||||
|  | ||||
| 		// Color property | ||||
| 		`<span style="color: red">Hello World</span>`, `<span style="color: red">Hello World</span>`, | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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" | ||||
| ) | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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("<a>< < 1</a>"), HTMLFormat("<a>%s %s %d</a>", "<", template.HTML("<"), 1)) | ||||
| } | ||||
|  | ||||
| func TestSanitizeHTML(t *testing.T) { | ||||
| 	assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), SanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`)) | ||||
| } | ||||
|   | ||||
| @@ -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" | ||||
| ) | ||||
|  | ||||
|   | ||||
| @@ -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(`<div class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s">%s</div>`, | ||||
| 		return htmlutil.HTMLFormat(`<div class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s">%s</div>`, | ||||
| 			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(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+ | ||||
| 	return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+ | ||||
| 		`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+ | ||||
| 		`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+ | ||||
| 		`</span>`, | ||||
|   | ||||
| @@ -113,34 +113,34 @@ func TestRenderCommitBody(t *testing.T) { | ||||
| 	} | ||||
|  | ||||
| 	expected := `/just/a/path.bin | ||||
| <a href="https://example.com/file.bin" class="link">https://example.com/file.bin</a> | ||||
| <a href="https://example.com/file.bin">https://example.com/file.bin</a> | ||||
| [local link](file.bin) | ||||
| [remote link](<a href="https://example.com" class="link">https://example.com</a>) | ||||
| [remote link](<a href="https://example.com">https://example.com</a>) | ||||
| [[local link|file.bin]] | ||||
| [[remote link|<a href="https://example.com" class="link">https://example.com</a>]] | ||||
| [[remote link|<a href="https://example.com">https://example.com</a>]] | ||||
|  | ||||
|  | ||||
|  | ||||
| [[local image|image.jpg]] | ||||
| [[remote link|<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>]] | ||||
| [[remote link|<a href="https://example.com/image.jpg">https://example.com/image.jpg</a>]] | ||||
| <a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code class="nohighlight">88fc37a3c0...12fc37a3c0 (hash)</code></a> | ||||
| com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare | ||||
| <a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code class="nohighlight">88fc37a3c0</code></a> | ||||
| com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit | ||||
| <span class="emoji" aria-label="thumbs up">👍</span> | ||||
| <a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a> | ||||
| <a href="/mention-user" class="mention">@mention-user</a> test | ||||
| <a href="mailto:mail@domain.com">mail@domain.com</a> | ||||
| <a href="/mention-user">@mention-user</a> test | ||||
| <a href="/user13/repo11/issues/123" class="ref-issue">#123</a> | ||||
|   space` | ||||
| 	assert.EqualValues(t, expected, string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas))) | ||||
| } | ||||
|  | ||||
| func TestRenderCommitMessage(t *testing.T) { | ||||
| 	expected := `space <a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a>  ` | ||||
| 	expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a>  ` | ||||
| 	assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas)) | ||||
| } | ||||
|  | ||||
| func TestRenderCommitMessageLinkSubject(t *testing.T) { | ||||
| 	expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a>` | ||||
| 	expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>` | ||||
| 	assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas)) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 wxiaoguang
					wxiaoguang