mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-12 11:57:38 +00:00
Every line of file, blame, diff and code preview views goes through `EscapeControlHTML`, which streamed it through a 32 KiB read buffer and copied every tag and text part. Views render the `EscapeStatus` before the content, so every caller holds the complete HTML and streaming can't help. It is now escaped in one pass over the string and returned unchanged when nothing needs escaping. - Skip text of printable ASCII, tabs and newlines, such lines return immediately - Render markup and wiki pages into a buffer instead of a pipe and goroutine, and remove `EscapeControlReader` - Analyze long text parts in windows overlapping by the detection context, which bounds memory without changing results - Escape invalid bytes before a tag, they were moved into the tag part unescaped and could hide a `<math>` tag | | before | after | |---|---|---| | ASCII line | 5.1 µs, 59 allocs | 0.08 µs, 1 alloc | | line with one `é` | 3.9 µs, 30 allocs | 0.29 µs, 3 allocs | | 150 KB document | 2.5 ms, 5.6 MB | 0.27 ms, 0.6 KB | --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: silverwind <me@silverwind.io>
296 lines
9.2 KiB
Go
296 lines
9.2 KiB
Go
// Copyright 2014 The Gogs Authors. All rights reserved.
|
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package markdown
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"html/template"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
|
|
"gitea.dev/modules/htmlutil"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/markup"
|
|
"gitea.dev/modules/markup/common"
|
|
"gitea.dev/modules/markup/markdown/math"
|
|
"gitea.dev/modules/setting"
|
|
giteautil "gitea.dev/modules/util"
|
|
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/ast"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
"github.com/yuin/goldmark/text"
|
|
"github.com/yuin/goldmark/util"
|
|
)
|
|
|
|
var (
|
|
renderContextKey = parser.NewContextKey()
|
|
renderConfigKey = parser.NewContextKey()
|
|
)
|
|
|
|
type limitWriter struct {
|
|
w io.Writer
|
|
sum int64
|
|
limit int64
|
|
}
|
|
|
|
// Write implements the standard Write interface:
|
|
func (l *limitWriter) Write(data []byte) (int, error) {
|
|
leftToWrite := l.limit - l.sum
|
|
if leftToWrite < int64(len(data)) {
|
|
n, err := l.w.Write(data[:leftToWrite])
|
|
l.sum += int64(n)
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
return n, errors.New("rendered content too large - truncating render")
|
|
}
|
|
n, err := l.w.Write(data)
|
|
l.sum += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
// newParserContext creates a parser.Context with the render context set
|
|
func newParserContext(ctx *markup.RenderContext) parser.Context {
|
|
pc := parser.NewContext()
|
|
pc.Set(renderContextKey, ctx)
|
|
return pc
|
|
}
|
|
|
|
type goldmarkEmphasisParser struct {
|
|
parser.InlineParser
|
|
}
|
|
|
|
func goldmarkNewEmphasisParser() parser.InlineParser {
|
|
return &goldmarkEmphasisParser{parser.NewEmphasisParser()}
|
|
}
|
|
|
|
func (s *goldmarkEmphasisParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
|
line, _ := block.PeekLine()
|
|
if len(line) > 1 && line[0] == '_' {
|
|
// a special trick to avoid parsing emphasis in filenames like "module/__init__.py"
|
|
end := bytes.IndexByte(line[1:], '_')
|
|
mark := bytes.Index(line, []byte("_.py"))
|
|
// check whether the "end" matches "_.py" or "__.py"
|
|
if mark != -1 && (end == mark || end == mark-1) {
|
|
return nil
|
|
}
|
|
}
|
|
return s.InlineParser.Parse(parent, block, pc)
|
|
}
|
|
|
|
func goldmarkDefaultParser() parser.Parser {
|
|
return parser.NewParser(parser.WithBlockParsers(parser.DefaultBlockParsers()...),
|
|
parser.WithInlineParsers([]util.PrioritizedValue{
|
|
util.Prioritized(parser.NewCodeSpanParser(), 100),
|
|
util.Prioritized(parser.NewLinkParser(), 200),
|
|
util.Prioritized(parser.NewAutoLinkParser(), 300),
|
|
util.Prioritized(parser.NewRawHTMLParser(), 400),
|
|
util.Prioritized(goldmarkNewEmphasisParser(), 500),
|
|
}...),
|
|
parser.WithParagraphTransformers(parser.DefaultParagraphTransformers()...),
|
|
)
|
|
}
|
|
|
|
// SpecializedMarkdown sets up the Gitea specific markdown extensions
|
|
func SpecializedMarkdown(ctx *markup.RenderContext) goldmark.Markdown {
|
|
// 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)
|
|
return goldmark.New(
|
|
goldmark.WithParser(goldmarkDefaultParser()),
|
|
goldmark.WithExtensions(
|
|
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
|
|
extension.Strikethrough,
|
|
extension.TaskList,
|
|
extension.DefinitionList,
|
|
common.FootnoteExtension,
|
|
math.NewExtension(&ctx.RenderInternal, math.Options{
|
|
Enabled: setting.Markdown.EnableMath,
|
|
ParseInlineDollar: setting.Markdown.MathCodeBlockOptions.ParseInlineDollar,
|
|
ParseInlineParentheses: setting.Markdown.MathCodeBlockOptions.ParseInlineParentheses, // this is a bad syntax "\( ... \)", it conflicts with normal markdown escaping
|
|
ParseBlockDollar: setting.Markdown.MathCodeBlockOptions.ParseBlockDollar,
|
|
ParseBlockSquareBrackets: setting.Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets, // this is a bad syntax "\[ ... \]", it conflicts with normal markdown escaping
|
|
}),
|
|
),
|
|
goldmark.WithParserOptions(
|
|
parser.WithAttribute(),
|
|
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
|
|
),
|
|
goldmark.WithRendererOptions(
|
|
html.WithUnsafe(),
|
|
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
|
|
),
|
|
)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
buf, err := io.ReadAll(input) // no streaming by design, link reference definitions may follow their use
|
|
if err != nil {
|
|
log.Error("Unable to ReadAll: %v", err)
|
|
return err
|
|
}
|
|
buf = giteautil.NormalizeEOL(buf)
|
|
|
|
// FIXME: should we include a timeout to abort the renderer if it takes too long?
|
|
defer func() {
|
|
err := recover()
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
log.Error("Panic in markdown: %v\n%s", err, log.Stack(2))
|
|
escapedHTML := template.HTMLEscapeString(giteautil.UnsafeBytesToString(buf))
|
|
_, _ = output.Write(giteautil.UnsafeStringToBytes(escapedHTML))
|
|
}()
|
|
|
|
pc := newParserContext(ctx)
|
|
|
|
// Preserve original length.
|
|
bufWithMetadataLength := len(buf)
|
|
|
|
rc := &RenderConfig{Meta: markup.RenderMetaAsDetails}
|
|
buf, _ = ExtractMetadataBytes(buf, rc)
|
|
|
|
metaLength := max(bufWithMetadataLength-len(buf), 0)
|
|
rc.metaLength = metaLength
|
|
|
|
pc.Set(renderConfigKey, rc)
|
|
|
|
if err := converter.Convert(buf, lw, parser.WithContext(pc)); err != nil {
|
|
log.Error("Unable to render: %v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkupName describes markup's name
|
|
var MarkupName = "markdown"
|
|
|
|
func init() {
|
|
markup.RegisterRenderer(Renderer{})
|
|
}
|
|
|
|
type Renderer struct{}
|
|
|
|
var _ markup.PostProcessRenderer = (*Renderer)(nil)
|
|
|
|
func (Renderer) Name() string {
|
|
return MarkupName
|
|
}
|
|
|
|
func (Renderer) NeedPostProcess() bool { return true }
|
|
|
|
func (Renderer) FileNamePatterns() []string {
|
|
return setting.Markdown.FileNamePatterns
|
|
}
|
|
|
|
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
|
return []setting.MarkupSanitizerRule{}
|
|
}
|
|
|
|
func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
|
return render(ctx, input, output)
|
|
}
|
|
|
|
// Render renders Markdown to HTML with all specific handling stuff.
|
|
func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
|
ctx.RenderOptions.MarkupType = MarkupName
|
|
return markup.Render(ctx, input, output)
|
|
}
|
|
|
|
// RenderString renders Markdown string to HTML with all specific handling stuff and return string
|
|
func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) {
|
|
var buf strings.Builder
|
|
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
|
|
log.Warn("Unable to RenderString: %v, content: %s", err, giteautil.TruncateRunes(content, 200))
|
|
err = nil
|
|
return htmlutil.EscapeString(content), err
|
|
}
|
|
return template.HTML(buf.String()), nil
|
|
}
|
|
|
|
// FeedExcerpt returns the source of the prose rendered by a feed excerpt, truncated outside of inline markup.
|
|
func FeedExcerpt(ctx context.Context, content string) string {
|
|
rctx := markup.NewRenderContext(ctx)
|
|
rctx.RenderOptions.FeedExcerpt = true
|
|
pc := newParserContext(rctx)
|
|
pc.Set(renderConfigKey, &RenderConfig{})
|
|
start, texts := len(content), []text.Segment(nil)
|
|
|
|
mdParser := SpecializedMarkdown(rctx).Parser()
|
|
mdReader := text.NewReader([]byte(content))
|
|
mdWalker := func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
if entering && node.Type() == ast.TypeBlock && node.Lines().Len() > 0 {
|
|
start = min(start, strings.LastIndexByte(content[:node.Lines().At(0).Start], '\n')+1)
|
|
} else if textNode, ok := node.(*ast.Text); ok && entering && node.Parent().Type() == ast.TypeBlock {
|
|
texts = append(texts, textNode.Segment)
|
|
}
|
|
return ast.WalkContinue, nil
|
|
}
|
|
_ = ast.Walk(mdParser.Parse(mdReader, parser.WithContext(pc)), mdWalker)
|
|
|
|
excerpt := giteautil.EllipsisDisplayString(content[start:], 200)
|
|
kept, ok := strings.CutSuffix(excerpt, "…")
|
|
if excerpt == content[start:] || !ok {
|
|
return excerpt
|
|
}
|
|
end := start + len(kept)
|
|
for _, segment := range slices.Backward(texts) {
|
|
if segment.Start < end {
|
|
end = min(end, segment.Stop)
|
|
break
|
|
}
|
|
}
|
|
excerpt = content[start:end]
|
|
// in case the content is in a Latin family language, we remove the last broken word.
|
|
if lastSpaceIdx := strings.LastIndexByte(excerpt, ' '); lastSpaceIdx != -1 && len(excerpt)-lastSpaceIdx+len("…") < 15 {
|
|
excerpt = excerpt[:lastSpaceIdx]
|
|
}
|
|
return excerpt + "…"
|
|
}
|
|
|
|
// RenderRaw renders Markdown to HTML without handling special links.
|
|
func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
|
rd, wr := io.Pipe()
|
|
defer func() {
|
|
_ = rd.Close()
|
|
_ = wr.Close()
|
|
}()
|
|
|
|
go func() {
|
|
if err := render(ctx, input, wr); err != nil {
|
|
_ = wr.CloseWithError(err)
|
|
return
|
|
}
|
|
_ = wr.Close()
|
|
}()
|
|
|
|
return markup.SanitizeReader(rd, "", output)
|
|
}
|
|
|
|
// RenderRawString renders Markdown to HTML without handling special links and return string
|
|
func RenderRawString(ctx *markup.RenderContext, content string) (string, error) {
|
|
var buf strings.Builder
|
|
if err := RenderRaw(ctx, strings.NewReader(content), &buf); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|