feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
@@ -1,49 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asciicast
import (
"fmt"
"io"
"net/url"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
)
func init() {
markup.RegisterRenderer(Renderer{})
}
// Renderer implements markup.Renderer for asciicast files.
// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
type Renderer struct{}
func (Renderer) Name() string {
return "asciicast"
}
func (Renderer) FileNamePatterns() []string {
return []string{"*.cast"}
}
const (
playerClassName = "asciinema-player-container"
playerSrcAttr = "data-asciinema-player-src"
)
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}}
}
func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error {
rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s",
setting.AppSubURL,
url.PathEscape(ctx.RenderOptions.Metas["user"]),
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
url.PathEscape(ctx.RenderOptions.RelativePath),
)
return ctx.RenderInternal.FormatWithSafeAttrs(output, `<div class="%s" %s="%s"></div>`, playerClassName, playerSrcAttr, rawURL)
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"net/url"
"strings"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/setting"
)
// CamoEncode encodes a lnk to fit with the go-camo and camo proxy links. The purposes of camo-proxy are:
@@ -6,7 +6,7 @@ package markup
import (
"testing"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
@@ -8,10 +8,10 @@ import (
"io"
"unicode/utf8"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
"gitea.dev/modules/typesniffer"
"gitea.dev/modules/util"
trend "github.com/buildkite/terminal-to-html/v3"
)
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/typesniffer"
"gitea.dev/modules/markup"
"gitea.dev/modules/typesniffer"
"github.com/stretchr/testify/assert"
)
@@ -9,11 +9,11 @@ import (
"io"
"strconv"
"code.gitea.io/gitea/modules/csv"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/csv"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
)
func init() {
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/markup"
"gitea.dev/modules/markup"
"github.com/stretchr/testify/assert"
)
+41 -25
View File
@@ -5,16 +5,16 @@ package external
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/markup"
"gitea.dev/modules/process"
"gitea.dev/modules/setting"
"github.com/kballard/go-shellquote"
)
@@ -48,6 +48,11 @@ func RegisterRenderers() {
},
})
markup.RegisterRenderer(&frontendRenderer{
name: "asciicast",
patterns: []string{"*.cast"},
})
for _, renderer := range setting.ExternalMarkupRenderers {
markup.RegisterRenderer(&Renderer{renderer})
}
@@ -86,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
return ret
}
func envMark(envName string) string {
if runtime.GOOS == "windows" {
return "%" + envName + "%"
func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
fields, err := shellquote.Split(strings.TrimSpace(p.Command))
if err != nil {
return "", nil, err
}
return "$" + envName
if len(fields) == 0 {
return "", nil, errors.New("no command")
}
var replacements []string
for k, v := range vars {
replacements = append(replacements, "$"+k, v)
replacements = append(replacements, "%"+k+"%", v) // for legacy Windows-style support
}
r := strings.NewReplacer(replacements...)
for i := range fields {
fields[i] = r.Replace(fields[i])
}
return fields[0], fields[1:], nil
}
// Render renders the data of the document to HTML via the external tool.
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
command := strings.NewReplacer(
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
).Replace(p.Command)
commands, err := shellquote.Split(command)
if err != nil || len(commands) == 0 {
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
cmdVars := map[string]string{
"GITEA_PREFIX_SRC": baseLinkSrc,
"GITEA_PREFIX_RAW": baseLinkRaw,
}
cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
if err != nil {
return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
}
args := commands[1:]
if p.IsInputFile {
// write to temp file
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
tmpFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
if err != nil {
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
}
defer cleanup()
_, err = io.Copy(f, input)
_, err = io.Copy(tmpFile, input)
if err != nil {
_ = f.Close()
_ = tmpFile.Close()
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
}
err = f.Close()
err = tmpFile.Close()
if err != nil {
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
}
args = append(args, f.Name())
cmdArgs = append(cmdArgs, tmpFile.Name())
}
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc))
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
defer finished()
cmd := exec.CommandContext(processCtx, commands[0], args...)
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
cmd.Env = append(
os.Environ(),
"GITEA_PREFIX_SRC="+baseLinkSrc,
@@ -146,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
process.SetSysProcAttribute(cmd)
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())
}
return nil
}
@@ -0,0 +1,24 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package external
import (
"testing"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestPrepareExternalCommand(t *testing.T) {
r := &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: ""}}
_, _, err := r.prepareExternalCommand(map[string]string{"KEY": "val"})
assert.ErrorContains(t, err, "no command")
r = &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: `"/foo bar/bin" --opt $KEY "$KEY" %KEY% other`}}
prog, args, err := r.prepareExternalCommand(map[string]string{"KEY": `a"b`})
assert.NoError(t, err)
assert.Equal(t, "/foo bar/bin", prog)
assert.Equal(t, []string{"--opt", `a"b`, `a"b`, `a"b`, "other"}, args)
}
+10 -17
View File
@@ -5,14 +5,15 @@ package external
import (
"encoding/base64"
"errors"
"io"
"unicode/utf8"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup"
"gitea.dev/modules/public"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
type frontendRenderer struct {
@@ -20,19 +21,12 @@ type frontendRenderer struct {
patterns []string
}
var (
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
)
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
func (p *frontendRenderer) Name() string {
return p.name
}
func (p *frontendRenderer) NeedPostProcess() bool {
return false
}
func (p *frontendRenderer) FileNamePatterns() []string {
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
// There are some approaches to make it more accurate, but they are all complicated:
@@ -54,14 +48,13 @@ func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
ret.SanitizerDisabled = true
ret.DisplayInIframe = true
ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
ret.ContentSandbox = setting.MarkupRenderDefaultSandbox
return ret
}
func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
if ctx.RenderOptions.StandalonePageOptions == nil {
opts := p.GetExternalRendererOptions()
return markup.RenderIFrame(ctx, &opts, output)
return errors.New("should only be rendered in standalone page")
}
content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
@@ -90,6 +83,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou
</html>`,
p.name, ctx.RenderOptions.RelativePath,
contentEncoding, contentString,
public.AssetURI("js/external-render-frontend.js"))
public.AssetURI("web_src/js/external-render-frontend.ts"))
return err
}
+47 -25
View File
@@ -6,15 +6,18 @@ package markup
import (
"bytes"
"fmt"
"html/template"
"io"
"regexp"
"slices"
"strings"
"sync"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/translation"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/log"
"gitea.dev/modules/markup/common"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
@@ -149,9 +152,8 @@ func PostProcessDefault(ctx *RenderContext, input io.Reader, output io.Writer) e
return postProcess(ctx, procs, input, output)
}
// PostProcessCommitMessage will use the same logic as PostProcess, but will disable
// the shortLinkProcessor.
func PostProcessCommitMessage(ctx *RenderContext, content string) (string, error) {
// PostProcessCommitMessage will use the same logic as PostProcess, but will disable the shortLinkProcessor.
func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) template.HTML {
procs := []processor{
fullIssuePatternProcessor,
comparePatternProcessor,
@@ -165,7 +167,7 @@ func PostProcessCommitMessage(ctx *RenderContext, content string) (string, error
emojiProcessor,
emojiShortCodeProcessor,
}
return postProcessString(ctx, procs, content)
return postProcessHTML(ctx, procs, content)
}
var emojiProcessors = []processor{
@@ -173,16 +175,25 @@ var emojiProcessors = []processor{
emojiProcessor,
}
// isBareURLSubject reports whether the (HTML-escaped) commit subject content
// is entirely a single URL, ignoring leading/trailing whitespace.
func isBareURLSubject(content string) bool {
s := strings.TrimSpace(html.UnescapeString(content))
if s == "" {
return false
}
m := common.GlobalVars().LinkRegex.FindStringIndex(s)
return m != nil && m[0] == 0 && m[1] == len(s)
}
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
// PostProcessCommitMessage, 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 PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
// emailAddressProcessor, and wraps the whole subject in defaultLink.
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, content template.HTML) template.HTML {
procs := []processor{
fullIssuePatternProcessor,
comparePatternProcessor,
fullHashPatternProcessor,
linkProcessor,
mentionProcessor,
issueIndexPatternProcessor,
commitCrossReferencePatternProcessor,
@@ -190,32 +201,42 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
emojiShortCodeProcessor,
emojiProcessor,
}
// When the whole subject is a bare URL, linkProcessor would turn it into
// a competing anchor and hijack the surrounding defaultLink wrapper, leaving
// the subject visually unclickable. Match GitHub: render such subjects as
// plain text inside defaultLink. Partial URLs inside larger text still become
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
// naturally breaks on that span, same as on GitHub).
if !isBareURLSubject(string(content)) {
procs = append(procs, linkProcessor)
}
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
node.Type = html.ElementNode
node.Data = "a"
node.DataAtom = atom.A
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted"}}
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}}
node.FirstChild, node.LastChild = ch, ch
})
return postProcessString(ctx, procs, content)
rendered := postProcessHTML(ctx, procs, content)
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)
}
// PostProcessIssueTitle to process title on individual issue/pull page
func PostProcessIssueTitle(ctx *RenderContext, title string) (string, error) {
return postProcessString(ctx, []processor{
func PostProcessIssueTitle(ctx *RenderContext, titleHTML template.HTML) template.HTML {
return postProcessHTML(ctx, []processor{
issueIndexPatternProcessor,
commitCrossReferencePatternProcessor,
hashCurrentPatternProcessor,
emojiShortCodeProcessor,
emojiProcessor,
}, title)
}, titleHTML)
}
// PostProcessDescriptionHTML will use similar logic as PostProcess, but will
// use a single special linkProcessor.
func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, error) {
return postProcessString(ctx, []processor{
func PostProcessDescriptionHTML(ctx *RenderContext, content template.HTML) template.HTML {
return postProcessHTML(ctx, []processor{
descriptionLinkProcessor,
emojiShortCodeProcessor,
emojiProcessor,
@@ -223,17 +244,18 @@ func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, err
}
// PostProcessEmoji for when we want to just process emoji and shortcodes
// in various places it isn't already run through the normal markdown processor
func PostProcessEmoji(ctx *RenderContext, content string) (string, error) {
return postProcessString(ctx, emojiProcessors, content)
// in various places it isn't already run through the normal Markdown processor
func PostProcessEmoji(ctx *RenderContext, content template.HTML) template.HTML {
return postProcessHTML(ctx, emojiProcessors, content)
}
func postProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
func postProcessHTML(ctx *RenderContext, procs []processor, content template.HTML) template.HTML {
var buf strings.Builder
if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
return "", err
if err := postProcess(ctx, procs, strings.NewReader(string(content)), &buf); err != nil {
log.Warn("postProcessHTML err: %v, input: %s", err, util.TruncateRunes(string(content), 200))
return content
}
return buf.String(), nil
return template.HTML(buf.String())
}
func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) {
@@ -9,8 +9,8 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"golang.org/x/net/html"
)
@@ -9,8 +9,8 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"github.com/stretchr/testify/assert"
)
@@ -22,7 +22,7 @@ func TestRenderCodePreview(t *testing.T) {
},
})
test := func(input, expected string) {
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithMarkupType(markdown.MarkupName), input)
buffer, err := testRenderString(markup.NewTestRenderContext().WithMarkupType(markdown.MarkupName), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -8,9 +8,9 @@ import (
"slices"
"strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/references"
"gitea.dev/modules/base"
"gitea.dev/modules/httplib"
"gitea.dev/modules/references"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
@@ -7,8 +7,8 @@ import (
"strings"
"unicode"
"code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/emoji"
"gitea.dev/modules/setting"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
@@ -69,6 +69,11 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
m[1] += start
start = m[1]
// don't render a shortcode whose ":" directly follows a backtick (an unclosed code span)
if m[0] > 0 && node.Data[m[0]-1] == '`' {
continue
}
alias := node.Data[m[0]:m[1]]
var nextChar byte
@@ -5,13 +5,14 @@ package markup
import (
"fmt"
"html/template"
"strconv"
"strings"
"testing"
"code.gitea.io/gitea/modules/setting"
testModule "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/setting"
testModule "gitea.dev/modules/test"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -260,9 +261,8 @@ func TestRender_PostProcessIssueTitle(t *testing.T) {
"repo": "someRepo",
"style": IssueNameStyleNumeric,
}
actual, err := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
assert.NoError(t, err)
assert.Equal(t, "#1", actual)
actual := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
assert.Equal(t, template.HTML("#1"), actual)
}
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
@@ -8,13 +8,13 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/regexplru"
"code.gitea.io/gitea/modules/templates/vars"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/references"
"gitea.dev/modules/regexplru"
"gitea.dev/modules/templates/vars"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
@@ -9,10 +9,10 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
testModule "code.gitea.io/gitea/modules/test"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
testModule "gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -8,8 +8,8 @@ import (
"path"
"strings"
"code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/markup/common"
"gitea.dev/modules/util"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
@@ -7,8 +7,8 @@ import (
"fmt"
"strings"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/references"
"gitea.dev/modules/util"
"golang.org/x/net/html"
)
@@ -6,7 +6,7 @@ package markup
import (
"strings"
"code.gitea.io/gitea/modules/markup/common"
"gitea.dev/modules/markup/common"
"golang.org/x/net/html"
)
@@ -8,12 +8,12 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
testModule "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/emoji"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
testModule "gitea.dev/modules/test"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -24,10 +24,16 @@ var (
localMetas = map[string]string{"user": testRepoOwnerName, "repo": testRepoName}
)
func testRenderString(ctx *markup.RenderContext, content string) (string, error) {
var buf strings.Builder
err := markup.Render(ctx, strings.NewReader(content), &buf)
return buf.String(), err
}
func TestRender_Commits(t *testing.T) {
test := func(input, expected string) {
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
buffer, err := markup.RenderString(rctx, input)
buffer, err := testRenderString(rctx, input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -75,7 +81,7 @@ func TestRender_CrossReferences(t *testing.T) {
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
buffer, err := markup.RenderString(rctx, input)
buffer, err := testRenderString(rctx, input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -119,7 +125,7 @@ func TestRender_links(t *testing.T) {
setting.AppURL = markup.TestAppURL
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -234,7 +240,7 @@ func TestRender_email(t *testing.T) {
setting.AppURL = markup.TestAppURL
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
res, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
res, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res), "input: %s", input)
}
@@ -317,11 +323,11 @@ func TestRender_email(t *testing.T) {
func TestRender_emoji(t *testing.T) {
setting.AppURL = markup.TestAppURL
setting.StaticURLPrefix = markup.TestAppURL
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/")
test := func(input, expected string) {
expected = strings.ReplaceAll(expected, "&", "&amp;")
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
@@ -371,6 +377,9 @@ func TestRender_emoji(t *testing.T) {
test(":100:200", `<p>:100:200</p>`)
test("std::thread::something", `<p>std::thread::something</p>`)
test(":not exist:", `<p>:not exist:</p>`)
test("foo `:smile:", "<p>foo `:smile:</p>")
test("foo `:smile:`", `<p>foo <code>:smile:</code></p>`)
test("foo ` :smile:", "<p>foo ` <span class=\"emoji\" aria-label=\"grinning face with smiling eyes\">😄</span></p>")
}
func TestRender_ShortLinks(t *testing.T) {
@@ -500,7 +509,7 @@ func Test_ParseClusterFuzz(t *testing.T) {
}
func TestPostProcess(t *testing.T) {
setting.StaticURLPrefix = markup.TestAppURL // can't run standalone
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/") // can't run standalone
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {
@@ -7,9 +7,9 @@ import (
"regexp"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/test"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
@@ -12,7 +12,7 @@ import (
"strings"
"sync"
"code.gitea.io/gitea/modules/htmlutil"
"gitea.dev/modules/htmlutil"
"golang.org/x/net/html"
)
@@ -77,6 +77,7 @@ func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML {
}
func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt template.HTML, a ...any) error {
_, err := w.Write([]byte(r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))))
htmlStr := r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))
_, err := io.WriteString(w, string(htmlStr))
return err
}
@@ -0,0 +1,74 @@
{
"metadata": {},
"nbformat": 4,
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["print('very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong')"],
"outputs": [
{
"output_type": "execute_result",
"text": ["very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong ...\n"]
},
{
"output_type": "stream",
"name": "stdout",
"text": ["stdout 1 ...\n", "stdout 2 ...\n"]
},
{
"output_type": "stream",
"name": "stderr",
"text": ["stderr ...\n"]
},
{
"data": {
"text/plain": ["data text 1\n", "data text 2\n"]
}
},
{
"data": {
"text/plain": true
}
},
{
"data": {
"image/svg+xml": ["<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2000\" height=\"20\"><rect width=\"2000\" height=\"20\" x=\"0\" y=\"0\" rx=\"5\" ry=\"5\" fill=\"red\"/></svg>"]
}
},
{
"data": {
"text/html": "<a href='/'>HTML Link</a>"
}
},
{
"data": {
"text/latex": "$$a=1$$"
}
},
{
"data": {
"text/plain": "plain text"
}
},
{
"output_type": "error",
"ename": "Error Name",
"traceback": ["stacktrace 1", "stacktrace 2"]
}
]
},
{
"cell_type": "unknown-cell"
},
{
"cell_type": "markdown",
"source": [
"# h1\n", "## h2\n", "### h3\n", "\n", "paragraph 1\n", "\n",
"very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong\n",
"- list item 1\n", "- list item 2\n", "\n", "```python\n", "print('code block')\n", "```\n",
"<table><tr><th>th1</th><th>th2</th></tr><tr><td>td1</td><td>td2</td></tr></table>\n"
]
}
]
}
@@ -0,0 +1,394 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jupyter
import (
"encoding/base64"
"fmt"
"html/template"
"io"
"strings"
"sync"
"gitea.dev/modules/highlight"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
func init() {
markup.RegisterRenderer(renderer{})
}
// Renderer implements markup.Renderer for Jupyter notebooks
type renderer struct{}
var (
_ markup.Renderer = (*renderer)(nil)
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
)
type mimeHandler struct {
Mime string
Fn func(w htmlutil.HTMLWriter, data string) error
}
func renderCellCodeOutputTextPlain(w htmlutil.HTMLWriter, text string) error {
w.WriteFormat(`<div class="cell-output-text"><pre>%s</pre></div>`, text)
return w.Err()
}
func renderCellCodeOutputUnsupported(w htmlutil.HTMLWriter, message string) error {
w.WriteFormat(`<div class="cell-output-unsupported">%s</div>`, message)
return w.Err()
}
var dataMimeHandlers = sync.OnceValue(func() []mimeHandler {
renderImage := func(w htmlutil.HTMLWriter, subtype, payload string) error {
w.WriteFormat(`<div class="cell-output-image"><img src="data:image/%s;base64,%s"></div>`, subtype, payload)
return w.Err()
}
renderUnsupportedOutput := func(message string) func(htmlutil.HTMLWriter, string) error {
return func(w htmlutil.HTMLWriter, _ string) error {
return renderCellCodeOutputUnsupported(w, message)
}
}
return []mimeHandler{
// Images (PNG, JPEG, SVG)
{"image/png", func(w htmlutil.HTMLWriter, d string) error {
return renderImage(w, "png", d)
}},
{"image/jpeg", func(w htmlutil.HTMLWriter, d string) error {
return renderImage(w, "jpeg", d)
}},
{"image/svg+xml", func(w htmlutil.HTMLWriter, d string) error {
return renderImage(w, "svg+xml", base64.StdEncoding.EncodeToString(util.UnsafeStringToBytes(d)))
}},
// Rich & Math Layouts
{"text/html", func(w htmlutil.HTMLWriter, d string) error {
// To future developers: don't allow custom CSS classes or attributes,
// because ".link-action" or "data-fetch-xxx" can send POST requests and lead to XSS.
// If you'd really like to support more, do remember to correctly sanitize the values.
w.WriteFormat(`<div class="cell-output-html">%s</div>`, markup.Sanitize(d))
return w.Err()
}},
{"text/latex", func(w htmlutil.HTMLWriter, d string) error {
w.WriteFormat(`<div class="cell-output-latex"><pre><code class="language-math display">%s</code></pre></div>`, trimMathDelimiters(d))
return w.Err()
}},
{"text/plain", renderCellCodeOutputTextPlain},
// Security Placeholders
{"application/javascript", renderUnsupportedOutput("[JavaScript output - execution disabled for security]")},
{"application/vnd.plotly.v1+json", renderUnsupportedOutput("[Plotly output - interactive plots not supported]")},
{"application/vnd.jupyter.widget-view+json", renderUnsupportedOutput("[Jupyter widget - interactive widgets not supported]")},
}
})
func (renderer) Name() string {
return "jupyter-render"
}
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
return markup.ExternalRendererOptions{
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
// This render must guarantee that the output is safe and no XSS
SanitizerDisabled: true,
}
}
func (renderer) FileNamePatterns() []string {
return []string{"*.ipynb"}
}
func (renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return nil
}
// Notebook structures
type Notebook struct {
Cells []Cell `json:"cells"`
Metadata map[string]any `json:"metadata"`
Nbformat int `json:"nbformat"`
}
type Cell struct {
CellType string `json:"cell_type"`
Source any `json:"source"` // string or []string
Outputs []Output `json:"outputs,omitempty"`
ExecutionCount any `json:"execution_count,omitempty"` // int or null
Metadata map[string]any `json:"metadata,omitempty"`
}
type Output struct {
OutputType string `json:"output_type"`
Data map[string]any `json:"data,omitempty"`
Text any `json:"text,omitempty"` // string or []string
Name string `json:"name,omitempty"`
Traceback any `json:"traceback,omitempty"` // []string
Ename string `json:"ename,omitempty"`
Evalue string `json:"evalue,omitempty"`
}
// Render renders Jupyter notebook to HTML
func (renderer) Render(ctx *markup.RenderContext, input io.Reader, outputWriter io.Writer) error {
htmlWriter := htmlutil.NewHTMLWriter(outputWriter)
// the size is (should be) checked and/or limited by the caller to avoid OOM
var notebook Notebook
if err := json.NewDecoder(input).Decode(&notebook); err != nil {
htmlWriter.WriteFormat(`<div class="ui error message">Failed to parse notebook JSON: %v</div>`, err)
return htmlWriter.Err()
}
// Check nbformat version
if notebook.Nbformat < 4 {
msg := htmlutil.HTMLFormat("This notebook uses an older format (nbformat %d). Only nbformat 4+ is supported for rendering. Please upgrade the notebook in Jupyter or view the raw JSON.", notebook.Nbformat)
htmlWriter.WriteFormat(`<div class="file-not-rendered-prompt">%s</div>`, msg)
return htmlWriter.Err()
}
// Detect language
language := "python" // default
if metadata, ok := notebook.Metadata["language_info"].(map[string]any); ok {
if name, ok := metadata["name"].(string); ok {
language = name
}
} else if kernelSpec, ok := notebook.Metadata["kernelspec"].(map[string]any); ok {
if lang, ok := kernelSpec["language"].(string); ok {
language = lang
}
}
// Start rendering
htmlWriter.WriteHTML(`<div class="jupyter-notebook">`)
// limiting the cell rendering to 100 cells
cells := notebook.Cells
truncated := false
const maxRenderedCells = 100
if len(cells) > maxRenderedCells {
cells = cells[:maxRenderedCells] // Slice down to exactly 100 elements instantly at the pointer layer
truncated = true
}
for _, cell := range cells {
if err := renderCell(ctx, htmlWriter, cell, language); err != nil {
log.Warn("Failed to render cell: %v", err) // TODO: RENDER-LOG-HANDLING: see other comments
continue
}
}
if truncated {
renderCellPrompt(htmlWriter, "Warning:", "Output truncated. This notebook contains too many cells to display efficiently.")
}
htmlWriter.WriteHTML(`</div>`)
return htmlWriter.Err()
}
func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) error {
source := joinSource(cell.Source)
var executionCount *int64
if cell.ExecutionCount != nil {
if count, err := util.ToInt64(cell.ExecutionCount); err == nil {
executionCount = &count
}
}
output.WriteHTML(`<div class="cell-line">`)
{
if executionCount != nil {
output.WriteFormat(`<div class="cell-left cell-prompt">In [%d]:</div>`, *executionCount)
} else {
output.WriteHTML(`<div class="cell-left cell-prompt">In [ ]:</div>`)
}
// Highlight code
lexer := highlight.DetectChromaLexerByFileName("", language)
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
output.WriteHTML("</code></pre></div>")
}
output.WriteHTML(`</div>`)
// Render outputs
if len(cell.Outputs) > 0 {
hasExecutionResult := false
for _, out := range cell.Outputs {
if out.OutputType == "execute_result" {
hasExecutionResult = true
break
}
}
output.WriteHTML(`<div class="cell-line">`)
{
if hasExecutionResult && executionCount != nil {
output.WriteFormat(`<div class="cell-left cell-prompt">Out [%d]:</div>`, *executionCount)
} else {
output.WriteHTML(`<div class="cell-left cell-prompt"></div>`)
}
output.WriteHTML(`<div class="cell-right cell-output">`)
for _, out := range cell.Outputs {
renderCellCodeOutput(output, out)
}
output.WriteHTML(`</div>`)
}
output.WriteHTML(`</div>`)
}
return output.Err()
}
func renderCellPrompt(output htmlutil.HTMLWriter, left, right template.HTML) {
output.WriteFormat(`
<div class="notebook-cell">
<div class="cell-line">
<div class="cell-left cell-prompt">%s</div>
<div class="cell-right cell-prompt">%s</div>
</div>
</div>`, left, right)
}
func renderCell(ctx *markup.RenderContext, output htmlutil.HTMLWriter, cell Cell, language string) error {
switch cell.CellType {
case "markdown":
output.WriteHTML(`
<div class="notebook-cell cell-type-markdown">
<div class="cell-line">
<div class="cell-left cell-prompt"></div>
<div class="cell-right">`)
if err := renderCellMarkdown(ctx, output, joinSource(cell.Source)); err != nil {
return err
}
output.WriteHTML(`
</div>
</div>
</div>`)
case "code":
output.WriteHTML(`<div class="notebook-cell cell-type-code">`)
if err := renderCellCode(output, cell, language); err != nil {
return err
}
output.WriteHTML(`</div>`)
default:
renderCellPrompt(output, "Cell:", htmlutil.HTMLFormat("[Cell type %s - unsupported, skipped]", cell.CellType))
}
return output.Err()
}
func renderCellMarkdown(rctx *markup.RenderContext, output htmlutil.HTMLWriter, source string) error {
markdownCtx := markup.NewRenderContext(rctx)
// make sure the markdown render use the same options and helper to generate correct contents (e.g.: links)
markdownCtx.RenderOptions = rctx.RenderOptions
markdownCtx.RenderHelper = rctx.RenderHelper
output.WriteHTML(`<div class="embedded-markdown">`)
if err := markdown.Render(markdownCtx, strings.NewReader(source), output.OriginWriter()); err != nil {
return err
}
output.WriteHTML(`</div>`)
return output.Err()
}
func renderCellCodeOutput(output htmlutil.HTMLWriter, out Output) {
if out.Data != nil {
// Iterate through our priority list to find the best matching MIME handler available
for _, h := range dataMimeHandlers() {
if rawPayload, exists := out.Data[h.Mime]; exists {
var stringPayload string
// Flatten the polymorphic JSON input (string or []any) into a single clean string
switch v := rawPayload.(type) {
case string:
stringPayload = v
case []any:
stringPayload = joinSource(v)
default:
_ = renderCellCodeOutputUnsupported(output, fmt.Sprintf("[Data output - unsupported data type %T for mime type %s]", rawPayload, h.Mime))
continue
}
if err := h.Fn(output, stringPayload); err != nil {
// TODO: RENDER-LOG-HANDLING: outputting render's error to sever's log is not a proper approach
// The errors can be:
// * unsupported element (cell, data, etc): it should render the message on the UI to tell users that the content is not supported, or ignore them if they are ignore-able
// * logic error: it should report to server logs
// * network error: io.Writer tries to write to the HTTP connection, so the error can also be a network error, such error should be ignored
log.Error("Jupyter rendering engine failed for MIME type %s: %v", h.Mime, err)
}
// Return immediately after rendering the top matching priority format
return
}
}
}
// Stream output
if out.OutputType == "stream" && out.Text != nil {
streamName := util.Iif(out.Name == "stderr", "stderr", "stdout")
output.WriteFormat(`<pre class="cell-output-stream stream-%s">%s</pre>`, streamName, joinSource(out.Text))
return
}
// Error output
if out.OutputType == "error" {
traceback := ""
if tb, ok := out.Traceback.([]any); ok {
lines := make([]string, len(tb))
for i, line := range tb {
lines[i] = fmt.Sprint(line)
}
traceback = strings.Join(lines, "\n")
}
if traceback == "" && out.Ename != "" {
traceback = fmt.Sprintf("%s: %s", out.Ename, out.Evalue)
}
output.WriteFormat(`<pre class="cell-output-error">%s</pre>`, traceback)
return
}
// Generic text output
if out.Text != nil {
_ = renderCellCodeOutputTextPlain(output, joinSource(out.Text))
}
}
func joinSource(source any) string {
switch v := source.(type) {
case nil:
return ""
case string:
return v
case []any:
// the "source slice item" has EOL ("\n"), so just join them together
parts := make([]string, len(v))
for i, part := range v {
parts[i] = fmt.Sprint(part)
}
return strings.Join(parts, "")
default:
return fmt.Sprint(v)
}
}
// trimMathDelimiters strips a single pair of surrounding math delimiters ("$$...$$" or "$...$"),
// so the inner expression is handled by the math post-processor. Unlike strings.Trim, it does not
// eat unrelated "$" characters elsewhere in multi-expression content.
func trimMathDelimiters(s string) string {
s = strings.TrimSpace(s)
if t, ok := strings.CutPrefix(s, "$$"); ok {
return strings.TrimSuffix(t, "$$")
}
if t, ok := strings.CutPrefix(s, "$"); ok {
return strings.TrimSuffix(t, "$")
}
return s
}
@@ -0,0 +1,314 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jupyter
import (
"fmt"
"strings"
"testing"
"gitea.dev/modules/markup"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestRender(t *testing.T) {
r := renderer{}
t.Run("Basic notebook", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["print('hello')"],
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": ["hello\n"]
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := &markup.RenderContext{}
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.Contains(t, result, `<div class="jupyter-notebook">`)
assert.Contains(t, result, `<div class="notebook-cell cell-type-code">`)
assert.Contains(t, result, `In [1]:`)
assert.Contains(t, result, `print`)
assert.Contains(t, result, `hello`)
assert.Contains(t, result, `stream-stdout`)
})
t.Run("Markdown cell with XSS Protection", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "markdown",
"source": [
"# Title\n",
"Some text\n",
"[click me](javascript:alert(1))\n",
"<script>alert('dangerous')</script>"
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
// Assert normal markup still renders correctly
assert.Contains(t, result, `<div class="notebook-cell cell-type-markdown">`)
assert.Contains(t, result, `Title`)
assert.Contains(t, result, `Some text`)
assert.Contains(t, result, `click me`)
// CRITICAL SECURITY ASSERTIONS: Ensure XSS vectors are completely stripped
assert.NotContains(t, result, `javascript:alert`)
assert.NotContains(t, result, `<script>`)
})
t.Run("Cell limit truncation guardrail", func(t *testing.T) {
// Generate an oversized notebook containing 105 cells dynamically
var cellBlocks []string
for range 105 {
cellBlocks = append(cellBlocks, `{"cell_type": "markdown", "source": ["cell text"]}`)
}
input := fmt.Sprintf(`{"cells": [%s], "metadata": {}, "nbformat": 4}`, strings.Join(cellBlocks, ","))
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
// Verify it halts rendering gracefully and shows the truncation warning
assert.Contains(t, result, "Output truncated.")
assert.Contains(t, result, "This notebook contains too many cells to display efficiently.")
// Count occurrences of the rendered cells to ensure it sliced down to exactly 100 elements
assert.Equal(t, 100, strings.Count(result, `class="notebook-cell cell-type-markdown"`))
})
t.Run("Image output", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["import matplotlib.pyplot as plt"],
"outputs": [
{
"output_type": "display_data",
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.Contains(t, result, `<img src="data:image/png;base64,`)
assert.Contains(t, result, `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`)
})
t.Run("HTML output with style tag", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["import pandas as pd"],
"outputs": [
{
"output_type": "execute_result",
"data": {
"text/html": ["<style scoped>.dataframe tbody tr th { vertical-align: top; }</style><table class=\"dataframe\"><tr><td>1</td></tr></table>"]
}
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.NotContains(t, result, `<style scoped>`)
assert.Contains(t, result, `<table><tr><td>1</td></tr></table>`)
assert.Contains(t, result, `<td>1</td>`)
})
t.Run("Error output", func(t *testing.T) {
input := `{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"source": ["raise ValueError('test error')"],
"outputs": [
{
"output_type": "error",
"ename": "ValueError",
"evalue": "test error",
"traceback": ["ValueError: test error"]
}
]
}
],
"metadata": {},
"nbformat": 4
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
result := output.String()
assert.Contains(t, result, `ValueError: test error`)
assert.Contains(t, result, `cell-output-error`)
})
t.Run("Old nbformat version", func(t *testing.T) {
input := `{
"cells": [],
"metadata": {},
"nbformat": 3
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
err := r.Render(ctx, strings.NewReader(input), &output)
assert.NoError(t, err)
assert.Regexp(t, `<div class="file-not-rendered-prompt">This notebook uses an older format.*</div>`, output.String())
})
}
func TestJoinSource(t *testing.T) {
tests := []struct {
name string
input any
expected string
}{
{
name: "String input",
input: "hello world",
expected: "hello world",
},
{
name: "Array input",
input: []any{"line1\n", "line2\n", "line3"},
expected: "line1\nline2\nline3",
},
{
name: "Empty array",
input: []any{},
expected: "",
},
{
name: "Single element array",
input: []any{"single"},
expected: "single",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := joinSource(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIntegrationAndSanitization(t *testing.T) {
// A mock malicious Jupyter notebook containing an XSS injection attempt
// inside a text/html output cell (e.g., pretending to be a poisoned Pandas DataFrame).
maliciousNotebook := `{
"nbformat": 4,
"nbformat_minor": 2,
"metadata": {"language_info":{"name":"any lang"}},
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"source": ["a=1"],
"outputs": [
{
"output_type": "execute_result",
"execution_count": 1,
"data": {
"text/html": [
"<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
]
},
"metadata": {}
}
]
}
]
}`
var output strings.Builder
ctx := markup.NewRenderContext(t.Context())
ctx.RenderOptions.MarkupType = "jupyter-render"
err := markup.Render(ctx, strings.NewReader(maliciousNotebook), &output)
assert.NoError(t, err)
const expected = `
<div class="jupyter-notebook">
<div class="notebook-cell cell-type-code">
<div class="cell-line">
<div class="cell-left cell-prompt">In [1]:</div>
<div class="cell-right cell-input">
<pre><code class="chroma language-fallback">
a=1
</code></pre>
</div>
</div>
<div class="cell-line">
<div class="cell-left cell-prompt">Out [1]:</div>
<div class="cell-right cell-output">
<div class="cell-output-html">
<div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
</div>
</div>
</div>
</div>
</div>`
assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(output.String()))
}
@@ -7,14 +7,13 @@ import (
"os"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
)
func TestMain(m *testing.M) {
setting.IsInTesting = true
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
setting.Markdown.FileNamePatterns = []string{"*.md"}
markup.RefreshFileNamePatterns()
os.Exit(m.Run())
}
@@ -95,13 +95,13 @@ type Icon struct {
// ColorPreview is an inline for a color preview
type ColorPreview struct {
ast.BaseInline
Color []byte
Color string
}
// Dump implements Node.Dump.
func (n *ColorPreview) Dump(source []byte, level int) {
m := map[string]string{}
m["Color"] = string(n.Color)
m["Color"] = n.Color
ast.DumpHelper(n, source, level, m, nil)
}
@@ -114,7 +114,7 @@ func (n *ColorPreview) Kind() ast.NodeKind {
}
// NewColorPreview returns a new Span node.
func NewColorPreview(color []byte) *ColorPreview {
func NewColorPreview(color string) *ColorPreview {
return &ColorPreview{
BaseInline: ast.BaseInline{},
Color: color,
@@ -170,3 +170,14 @@ func (n *RawHTML) Kind() ast.NodeKind {
func NewRawHTML(rawHTML template.HTML) *RawHTML {
return &RawHTML{rawHTML: rawHTML}
}
func childSingleText(node ast.Node, source []byte) (string, bool) {
if node.FirstChild() == nil || node.FirstChild() != node.LastChild() {
return "", false
}
c, ok := node.FirstChild().(*ast.Text)
if !ok {
return "", false
}
return string(c.Segment.Value(source)), true
}
@@ -6,12 +6,12 @@ package markdown
import (
"strings"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/svg"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/svg"
"github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func nodeToTable(meta *yaml.Node) ast.Node {
@@ -6,9 +6,9 @@ package markdown
import (
"fmt"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/internal"
"gitea.dev/modules/container"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/internal"
"github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
@@ -70,6 +70,8 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
}
case *ast.CodeSpan:
g.transformCodeSpan(ctx, v, reader)
case *ast.FencedCodeBlock:
g.transformFencedCodeblock(v, reader)
case *ast.Blockquote:
return g.transformBlockquote(v, reader)
}
@@ -117,12 +119,33 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(KindDetails, r.renderDetails)
reg.Register(KindSummary, r.renderSummary)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(KindAttention, r.renderAttention)
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
reg.Register(KindRawHTML, r.renderRawHTML)
}
// renderCodeBlock wraps indented code blocks like the fenced renderer
func (r *HTMLRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
opening := r.renderInternal.ProtectSafeAttrs(`<div class="code-block-container code-overflow-scroll"><pre class="code-block"><code>`)
if _, err := w.WriteString(string(opening)); err != nil {
return ast.WalkStop, err
}
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.Writer.RawWrite(w, line.Value(source))
}
} else {
if _, err := w.WriteString("</code></pre></div>"); err != nil {
return ast.WalkStop, err
}
}
return ast.WalkContinue, nil
}
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Document)
@@ -7,8 +7,8 @@ import (
"os"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
)
func TestMain(m *testing.M) {
@@ -11,12 +11,13 @@ import (
"io"
"strings"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/markup/markdown/math"
"code.gitea.io/gitea/modules/setting"
giteautil "code.gitea.io/gitea/modules/util"
"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"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/yuin/goldmark"
@@ -64,21 +65,17 @@ func newParserContext(ctx *markup.RenderContext) parser.Context {
return pc
}
type GlodmarkRender struct {
type GoldmarkRender struct {
ctx *markup.RenderContext
goldmarkMarkdown goldmark.Markdown
}
func (r *GlodmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
func (r *GoldmarkRender) 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) {
func (r *GoldmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
if entering {
languageBytes, _ := c.Language()
languageStr := giteautil.IfZero(string(languageBytes), "text")
@@ -139,10 +136,10 @@ func goldmarkDefaultParser() parser.Parser {
}
// SpecializedMarkdown sets up the Gitea specific markdown extensions
func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
// 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 := &GoldmarkRender{ctx: ctx}
r.goldmarkMarkdown = goldmark.New(
goldmark.WithParser(goldmarkDefaultParser()),
goldmark.WithExtensions(
@@ -272,7 +269,7 @@ func RenderString(ctx *markup.RenderContext, content string) (template.HTML, err
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 template.HTML(template.HTMLEscapeString(content)), err
return htmlutil.EscapeString(content), err
}
return template.HTML(buf.String()), nil
}
@@ -7,9 +7,9 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/svg"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/svg"
"github.com/stretchr/testify/assert"
"golang.org/x/text/cases"
@@ -6,8 +6,8 @@ package markdown_test
import (
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
)
func BenchmarkSpecializedMarkdown(b *testing.B) {
@@ -7,9 +7,9 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
@@ -9,11 +9,11 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
@@ -600,3 +600,24 @@ func TestMarkdownUlDir(t *testing.T) {
</ul>
`, string(result))
}
func TestMarkdownCodeBlock(t *testing.T) {
testRender := func(input, expected string) {
buffer, err := markdown.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
}
const nl = "\n"
const prefix = `<div class="code-block-container code-overflow-scroll"><pre class="code-block">`
const suffix = `</pre></div>`
testRender("```\ncode\n```", prefix+`<code class="chroma language-text display">code`+nl+`</code>`+suffix)
const jsCommon = prefix + `<code class="chroma language-js display"><span class="nx">code</span>` + nl + `</code>` + suffix
testRender("```js\ncode\n```", jsCommon)
testRender("```js:app.ts\ncode\n```", jsCommon)
testRender("```js,ignore\ncode\n```", jsCommon)
testRender("```js ignore\ncode\n```", jsCommon)
testRender(" code\n", prefix+`<code>code`+nl+`</code>`+suffix)
testRender(" <script>alert(1)</script>\n", prefix+`<code>&lt;script&gt;alert(1)&lt;/script&gt;`+nl+`</code>`+suffix)
}
@@ -6,7 +6,7 @@ package math
import (
"bytes"
giteaUtil "code.gitea.io/gitea/modules/util"
giteaUtil "gitea.dev/modules/util"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
@@ -6,8 +6,8 @@ package math
import (
"html/template"
"code.gitea.io/gitea/modules/markup/internal"
giteaUtil "code.gitea.io/gitea/modules/util"
"gitea.dev/modules/markup/internal"
giteaUtil "gitea.dev/modules/util"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
@@ -6,7 +6,7 @@ package math
import (
"bytes"
"code.gitea.io/gitea/modules/markup/internal"
"gitea.dev/modules/markup/internal"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
@@ -4,8 +4,8 @@
package math
import (
"code.gitea.io/gitea/modules/markup/internal"
giteaUtil "code.gitea.io/gitea/modules/util"
"gitea.dev/modules/markup/internal"
giteaUtil "gitea.dev/modules/util"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
@@ -9,7 +9,7 @@ import (
"unicode"
"unicode/utf8"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func isYAMLSeparator(line []byte) bool {
@@ -7,10 +7,10 @@ import (
"fmt"
"strings"
"code.gitea.io/gitea/modules/markup"
"gitea.dev/modules/markup"
"github.com/yuin/goldmark/ast"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
// RenderConfig represents rendering configuration for this file
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func TestRenderConfig_UnmarshalYAML(t *testing.T) {
@@ -6,7 +6,7 @@ package markdown
import (
"strings"
"code.gitea.io/gitea/modules/svg"
"gitea.dev/modules/svg"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
@@ -46,7 +46,10 @@ func (g *ASTTransformer) extractBlockquoteAttentionEmphasis(firstParagraph ast.N
if !ok {
return "", nil
}
val1 := string(node1.Text(reader.Source())) //nolint:staticcheck // Text is deprecated
val1, ok := childSingleText(node1, reader.Source())
if !ok {
return "", nil
}
attentionType := strings.ToLower(val1)
if g.attentionTypes.Contains(attentionType) {
return attentionType, []ast.Node{node1}
@@ -0,0 +1,32 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markdown
import (
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
func (g *ASTTransformer) transformFencedCodeblock(v *ast.FencedCodeBlock, reader text.Reader) {
// * Some engines support a meta syntax for appending the filename after the language, separated by a colon
// * https://www.glukhov.org/documentation-tools/markdown/markdown-codeblocks/
// * Some engines support additional "options" after the language, separated by a space or comma: ```rust,ignore```
// * https://docs.readme.com/rdmd/docs/code-blocks
// * https://next-book.vercel.app/reference/fencedcode
if v.Info == nil {
return
}
info := v.Info.Segment.Value(reader.Source())
newEnd := -1
for i, b := range info {
if b == ' ' || b == ',' || b == ':' {
newEnd = i
break
}
}
if newEnd != -1 {
start := v.Info.Segment.Start
v.Info = ast.NewTextSegment(text.NewSegment(start, start+newEnd))
}
}
@@ -7,7 +7,7 @@ import (
"bytes"
"strings"
"code.gitea.io/gitea/modules/markup"
"gitea.dev/modules/markup"
"github.com/microcosm-cc/bluemonday/css"
"github.com/yuin/goldmark/ast"
@@ -39,7 +39,7 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod
r.Writer.RawWrite(w, value)
}
case *ColorPreview:
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, string(v.Color))
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, v.Color)
}
}
return ast.WalkSkipChildren, nil
@@ -68,8 +68,11 @@ func cssColorHandler(value string) bool {
}
func (g *ASTTransformer) transformCodeSpan(_ *markup.RenderContext, v *ast.CodeSpan, reader text.Reader) {
colorContent := v.Text(reader.Source()) //nolint:staticcheck // Text is deprecated
if cssColorHandler(string(colorContent)) {
colorContent, ok := childSingleText(v, reader.Source())
if !ok {
return
}
if cssColorHandler(colorContent) {
v.AppendChild(v, NewColorPreview(colorContent))
}
}
@@ -6,7 +6,7 @@ package markdown
import (
"fmt"
"code.gitea.io/gitea/modules/markup"
"gitea.dev/modules/markup"
"github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
@@ -10,9 +10,9 @@ import (
"strings"
"sync"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/log"
"gitea.dev/modules/markup/common"
"gitea.dev/modules/setting"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
@@ -9,11 +9,11 @@ import (
"io"
"strings"
"code.gitea.io/gitea/modules/highlight"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/highlight"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
"github.com/alecthomas/chroma/v2"
"github.com/niklasfasching/go-org/org"
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
htmlWriter.ExtendingWriter = w
res, err := org.New().Silent().Parse(input, "").Write(w)
cfg := org.New()
cfg.ReadFile = func(path string) ([]byte, error) {
// actually the orgmode render doesn't support rendering the content from the content again,
// so just leave the plain text to end users
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
return []byte(content), nil
}
doc := cfg.Silent().Parse(input, "")
res, err := doc.Write(w)
if err != nil {
return fmt.Errorf("orgmode.Render failed: %w", err)
}
@@ -106,31 +114,27 @@ func (r *orgWriter) resolveLink(link string) string {
// WriteRegularLink renders images, links or videos
func (r *orgWriter) WriteRegularLink(l org.RegularLink) {
link := r.resolveLink(l.URL)
printHTML := func(html template.HTML, a ...any) {
_, _ = fmt.Fprint(r, htmlutil.HTMLFormat(html, a...))
}
// Inspired by https://github.com/niklasfasching/go-org/blob/6eb20dbda93cb88c3503f7508dc78cbbc639378f/org/html_writer.go#L406-L427
switch l.Kind() {
case "image":
if l.Description == nil {
printHTML(`<img src="%s" alt="%s">`, link, link)
_, _ = htmlutil.HTMLPrintf(r, `<img src="%s" alt="%s">`, link, link)
} else {
imageSrc := r.resolveLink(org.String(l.Description...))
printHTML(`<a href="%s"><img src="%s" alt="%s"></a>`, link, imageSrc, imageSrc)
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><img src="%s" alt="%s"></a>`, link, imageSrc, imageSrc)
}
case "video":
if l.Description == nil {
printHTML(`<video src="%s">%s</video>`, link, link)
_, _ = htmlutil.HTMLPrintf(r, `<video src="%s">%s</video>`, link, link)
} else {
videoSrc := r.resolveLink(org.String(l.Description...))
printHTML(`<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
}
default:
var description any = link
if l.Description != nil {
description = template.HTML(r.WriteNodesAsString(l.Description...)) // orgmode HTMLWriter outputs HTML content
}
printHTML(`<a href="%s">%s</a>`, link, description)
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s">%s</a>`, link, description)
}
}
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/orgmode"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/orgmode"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
func TestRender_StandardLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
func testRender(t *testing.T, input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[https://google.com/]]",
func TestRender_StandardLinks(t *testing.T) {
testRender(t, "[[https://google.com/]]",
`<p><a href="https://google.com/">https://google.com/</a></p>`)
test("[[ImageLink.svg][The Image Desc]]",
testRender(t, "[[ImageLink.svg][The Image Desc]]",
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
}
func TestRender_InternalLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:test.org][Test]]",
testRender(t, "[[file:test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[./test.org][Test]]",
testRender(t, "[[./test.org][Test]]",
`<p><a href="./test.org">Test</a></p>`)
test("[[test.org][Test]]",
testRender(t, "[[test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[path/to/test.org][Test]]",
testRender(t, "[[path/to/test.org][Test]]",
`<p><a href="path/to/test.org">Test</a></p>`)
}
func TestRender_Media(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:../../.images/src/02/train.jpg]]",
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
test("[[file:train.jpg]]",
testRender(t, "[[file:train.jpg]]",
`<p><img src="train.jpg" alt="train.jpg"></p>`)
// With description.
test("[[https://example.com][https://example.com/example.svg]]",
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
test("[[https://example.com][pre https://example.com/example.svg post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
test("[[https://example.com][https://example.com/example.mp4]]",
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
// Without description.
test("[[https://example.com/example.svg]]",
testRender(t, "[[https://example.com/example.svg]]",
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
test("[[https://example.com/example.mp4]]",
testRender(t, "[[https://example.com/example.mp4]]",
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
}
func TestRender_Source(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test(`#+begin_src c
testRender(t, `#+begin_src c
int a;
#+end_src
`, `<div class="src src-c">
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
</div>`)
}
func TestRender_IncludeLink(t *testing.T) {
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
</div>`)
}
+12 -21
View File
@@ -15,12 +15,12 @@ import (
"strings"
"time"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup/internal"
"gitea.dev/modules/public"
"gitea.dev/modules/setting"
"gitea.dev/modules/typesniffer"
"gitea.dev/modules/util"
"golang.org/x/sync/errgroup"
)
@@ -198,15 +198,6 @@ func Render(rctx *RenderContext, origInput io.Reader, output io.Writer) error {
return RenderWithRenderer(rctx, renderer, input, output)
}
// RenderString renders Markup string to HTML with all specific handling stuff and return string
func RenderString(ctx *RenderContext, content string) (string, error) {
var buf strings.Builder
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error {
ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"]
refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"]
@@ -220,11 +211,11 @@ func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.W
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
)
var extraAttrs template.HTML
if opts.ContentSandbox != "" {
extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox)
}
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
// The render response should always have correct "sandbox" limits (no same-origin),
// otherwise the "render link" direct access can still cause XSS without iframe.
// So here we do not need to set sandbox attribute on the iframe.
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, src)
return err
}
@@ -252,7 +243,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
return RenderIFrame(ctx, &extOpts, output)
}
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
extraScriptSrc := public.AssetURI("js/external-render-helper.js")
extraScriptSrc := public.AssetURI("web_src/js/external-render-helper.ts")
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
@@ -7,7 +7,7 @@ import (
"context"
"html/template"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/setting"
)
const (
@@ -9,8 +9,8 @@ import (
"path"
"strings"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/httplib"
"gitea.dev/modules/setting"
)
// resolveLinkRelative tries to resolve the link relative to the "{base}/{cur}", and returns the final link.
@@ -6,7 +6,7 @@ package markup
import (
"testing"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
@@ -22,10 +22,7 @@ func TestRenderIFrame(t *testing.T) {
WithRelativePath("tree-path").
WithMetas(map[string]string{"user": "test-owner", "repo": "test-repo", "RefTypeNameSubURL": "src/branch/master"})
// the value is read from config RENDER_CONTENT_SANDBOX, empty means "disabled"
ret := render(ctx, ExternalRendererOptions{ContentSandbox: ""})
// iframe doesn't need sandbox, the sandbox is set in render's response header
ret := render(ctx, ExternalRendererOptions{ContentSandbox: "any"})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, ret)
ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe" sandbox="allow"></iframe>`, ret)
}
@@ -8,8 +8,8 @@ import (
"path"
"strings"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"gitea.dev/modules/setting"
"gitea.dev/modules/typesniffer"
)
// Renderer defines an interface for rendering markup file to HTML
@@ -7,7 +7,7 @@ import (
"regexp"
"strings"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/setting"
"github.com/microcosm-cc/bluemonday"
)
@@ -9,7 +9,7 @@ import (
"net/url"
"regexp"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/setting"
"github.com/microcosm-cc/bluemonday"
)
@@ -63,6 +63,38 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
policy.AllowAttrs("loading").OnElements("img")
// MathML Core (https://www.w3.org/TR/mathml-core/)
mathMLElements := []string{
"math",
// token elements
"mi", "mn", "mo", "mtext", "mspace", "ms",
// layout elements
"mrow", "mfrac", "msqrt", "mroot", "mstyle", "merror", "mpadded", "mphantom",
// scripting elements
"msub", "msup", "msubsup", "munder", "mover", "munderover", "mmultiscripts", "mprescripts", "none",
// tabular elements
"mtable", "mtr", "mtd",
// semantic annotations
"semantics", "annotation", "annotation-xml",
}
policy.AllowAttrs("display", "alttext").OnElements("math")
policy.AllowAttrs(
// global presentation attributes
"dir", "displaystyle", "mathbackground", "mathcolor", "mathsize", "mathvariant", "scriptlevel",
// operator attributes
"accent", "accentunder", "fence", "form", "largeop", "lspace", "maxsize", "minsize", "movablelimits", "rspace", "separator", "stretchy", "symmetric",
// space and padding attributes
"depth", "height", "voffset", "width",
// fraction attribute
"linethickness",
// table attributes
"columnalign", "columnlines", "columnspacing", "frame", "framespacing", "rowalign", "rowlines", "rowspacing",
// cell attributes
"columnspan",
// annotation attribute
"encoding",
).OnElements(mathMLElements...)
// Allow generally safe attributes (reference: https://github.com/jch/html-pipeline)
generalSafeAttrs := []string{
"abbr", "accept", "accept-charset",
@@ -61,6 +61,9 @@ func TestSanitizer(t *testing.T) {
// picture
`<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`, `<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`,
// MathML
`<math display="display" class="foo"><mi mathcolor="c" class="bar"></mi></math>`, `<math display="display"><mi mathcolor="c"></mi></math>`,
// Disallow dangerous url schemes
`<a href="javascript:alert('xss')">bad</a>`, `bad`,
`<a href="vbscript:no">bad</a>`, `bad`,
@@ -72,6 +75,6 @@ func TestSanitizer(t *testing.T) {
}
for i := 0; i < len(testCases); i += 2 {
assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])))
assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])), "input: %s", testCases[i])
}
}