fix(markup): resolve and proxy srcset links of <source> elements (#39708) (#39756)

Backport #39708

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Giteabot
2026-10-13 15:46:51 +08:00
committed by GitHub
co-authored by Basavaraj S m wxiaoguang
parent f5fd936cbb
commit 63669003eb
9 changed files with 247 additions and 14 deletions
+18
View File
@@ -9,6 +9,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/test"
_ "gitea.dev/modules/markup/orgmode"
@@ -80,6 +81,23 @@ func TestRepoFile(t *testing.T) {
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
</video>`, rendered)
})
t.Run("PictureSource", func(t *testing.T) {
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}).
WithMarkupType(markdown.MarkupName)
rendered, err := testRenderString(rctx, `<picture><source media="(prefers-color-scheme: dark)" srcset="dark.svg"><img src="light.svg"></picture>`)
assert.NoError(t, err)
assert.Equal(t, test.NormalizeHTMLSpaces(`
<p>
<a href="/user2/repo1/src/branch/main/light.svg" target="_blank" rel="nofollow noopener">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/user2/repo1/media/branch/main/dark.svg"/>
<img src="/user2/repo1/media/branch/main/light.svg"/>
</picture>
</a>
</p>
`), test.NormalizeHTMLSpaces(rendered))
})
}
func TestRepoFileOrgMode(t *testing.T) {
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"strings"
)
type SrcSetCandidate struct {
SrcLink string
SrcDesc []string
}
const (
asciiSpaces = " \t\n\r\f"
asciiSpacesAndComma = " \t\n\r\f,"
)
func ParseSrcset(srcset string) (candidates []SrcSetCandidate) {
// ref: https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
// It can't simply use "split" because comma can appear in URLs and descriptors can be parenthesized and contain commas.
extractLink := func() (string, bool) {
srcset = strings.TrimLeft(srcset, asciiSpacesAndComma)
if srcset == "" {
return "", false
}
end := strings.IndexAny(srcset, asciiSpaces)
if end == -1 {
end = len(srcset)
}
link := srcset[:end]
srcset = srcset[end:]
if strings.HasSuffix(link, ",") {
return strings.TrimRight(link, ","), false
}
return link, true
}
extractDesc := func() (string, bool) {
srcset = strings.TrimLeft(srcset, asciiSpaces)
if srcset == "" {
return "", false
}
inParentheses := false
for i := range len(srcset) {
c := srcset[i]
if inParentheses {
inParentheses = c != ')'
continue
}
if c == '(' {
inParentheses = true
continue
}
if strings.IndexByte(asciiSpacesAndComma, c) != -1 {
desc := srcset[:i]
srcset = srcset[i+1:]
return desc, c != ','
}
}
desc := srcset
srcset = ""
return desc, false
}
for {
link, hasDesc := extractLink()
if link == "" {
break
}
var descriptors []string
if hasDesc {
for {
desc, hasMoreDesc := extractDesc()
if desc != "" {
descriptors = append(descriptors, desc)
}
if !hasMoreDesc {
break
}
}
}
candidates = append(candidates, SrcSetCandidate{SrcLink: link, SrcDesc: descriptors})
}
return candidates
}
func EncodeSrcset(candidates []SrcSetCandidate) string {
var sb strings.Builder
for i, c := range candidates {
if i != 0 {
sb.WriteString(", ")
}
link := c.SrcLink
if prefix, ok := strings.CutSuffix(link, ","); ok {
link = prefix + "%2C"
}
sb.WriteString(link)
for _, d := range c.SrcDesc {
sb.WriteByte(' ')
sb.WriteString(d)
}
}
return sb.String()
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"testing"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
func TestSrcset(t *testing.T) {
for _, test := range []struct {
name string
input string
candidates []SrcSetCandidate
encoded string
}{
{
name: "bare URL",
input: "image.png",
candidates: []SrcSetCandidate{{SrcLink: "image.png"}},
},
{
name: "bare URLs",
input: "a.png, b.png , c.png,",
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png"}, {SrcLink: "c.png"}},
encoded: "a.png, b.png, c.png",
},
{
name: "empty candidates",
input: ", ,\ta.png 1x,, ,b.png 2x, ,",
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
encoded: "a.png 1x, b.png 2x",
},
{
name: "trailing commas in URL",
input: "a.png,, b.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
encoded: "a.png, b.png 2x",
},
{
name: "comma in URL",
input: "comma,in,url.png",
candidates: []SrcSetCandidate{{SrcLink: "comma,in,url.png"}},
},
{
name: "embedded data URL",
input: "data:image/png;base64,abcd 1x, image.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "data:image/png;base64,abcd", SrcDesc: []string{"1x"}}, {SrcLink: "image.png", SrcDesc: []string{"2x"}}},
},
{
name: "parenthesized descriptor",
input: "a.png (x,y) 1x, b.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"(x,y)", "1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
},
} {
t.Run(test.name, func(t *testing.T) {
candidates := ParseSrcset(test.input)
assert.Equal(t, test.candidates, candidates)
encoded := EncodeSrcset(candidates)
assert.Equal(t, util.IfZero(test.encoded, test.input), encoded)
})
}
t.Run("encode trailing commas", func(t *testing.T) {
encoded := EncodeSrcset([]SrcSetCandidate{
{SrcLink: "image,"},
{SrcLink: "image,,", SrcDesc: []string{"2x"}},
{SrcLink: "image.svg?variant=light,", SrcDesc: []string{"3x"}},
})
assert.Equal(t, "image%2C, image,%2C 2x, image.svg?variant=light%2C 3x", encoded)
assert.Equal(t, []SrcSetCandidate{
{SrcLink: "image%2C"},
{SrcLink: "image,%2C", SrcDesc: []string{"2x"}},
{SrcLink: "image.svg?variant=light%2C", SrcDesc: []string{"3x"}},
}, ParseSrcset(encoded))
})
}
+1 -1
View File
@@ -36,7 +36,7 @@ func b64encode(data []byte) string {
func camoHandleLink(link string) string {
if setting.Camo.Enabled {
lnkURL, err := url.Parse(link)
if err == nil && lnkURL.IsAbs() && !strings.HasPrefix(link, setting.AppURL) &&
if err == nil && (lnkURL.Scheme == "http" || lnkURL.Scheme == "https") && !strings.HasPrefix(link, setting.AppURL) &&
(setting.Camo.Always || lnkURL.Scheme != "https") {
return CamoEncode(link)
}
+4
View File
@@ -15,8 +15,11 @@ func TestCamoHandleLink(t *testing.T) {
setting.AppURL = "https://gitea.com"
// Test media proxy
setting.Camo.Enabled = true
setting.Camo.Always = false
setting.Camo.ServerURL = "https://image.proxy"
setting.Camo.HMACKey = "geheim"
dataURL := "data:image/png;base64,i//V"
assert.Equal(t, dataURL, camoHandleLink(dataURL))
assert.Equal(t,
"https://gitea.com/img.jpg",
@@ -29,6 +32,7 @@ func TestCamoHandleLink(t *testing.T) {
camoHandleLink("http://testimages.org/img.jpg"))
setting.Camo.Always = true
assert.Equal(t, dataURL, camoHandleLink(dataURL))
assert.Equal(t,
"https://gitea.com/img.jpg",
camoHandleLink("https://gitea.com/img.jpg"))
+4 -1
View File
@@ -355,6 +355,8 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
return visitNodeImg(ctx, node)
} else if node.Data == "video" {
return visitNodeVideo(ctx, node)
} else if node.Data == "source" {
return visitNodePictureSource(ctx, node)
}
if node.Data == "a" {
@@ -363,10 +365,11 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
// because the content there is not processable, for example: the content is a commit id or a full URL.
procs = emojiProcessors
}
next := node.NextSibling
for n := node.FirstChild; n != nil; {
n = visitNode(ctx, procs, n)
}
return node.NextSibling
return next
}
// createKeyword() renders a highlighted version of an action keyword
+32 -10
View File
@@ -6,6 +6,7 @@ package markup
import (
"strings"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup/common"
"golang.org/x/net/html"
@@ -127,6 +128,10 @@ func processNodeA(ctx *RenderContext, node *html.Node) {
}
}
func resolveMediaLinkWithCamo(ctx *RenderContext, link string) string {
return camoHandleLink(ctx.RenderHelper.ResolveLink(link, LinkTypeMedia))
}
func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
next = img.NextSibling
attrSrc, hasLazy := "", false
@@ -152,19 +157,21 @@ func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
cnt++
}
if isLinkable {
linkNode := img
if img.Parent.Data == "picture" {
linkNode = img.Parent
}
wrapper := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{
{Key: "href", Val: ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeDefault)},
{Key: "target", Val: "_blank"},
}}
parent := img.Parent
imgNext := img.NextSibling
parent.RemoveChild(img)
parent.InsertBefore(wrapper, imgNext)
wrapper.AppendChild(img)
parent := linkNode.Parent
linkNext := linkNode.NextSibling
parent.RemoveChild(linkNode)
parent.InsertBefore(wrapper, linkNext)
wrapper.AppendChild(linkNode)
}
imgAttr.Val = ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeMedia)
imgAttr.Val = camoHandleLink(imgAttr.Val)
imgAttr.Val = resolveMediaLinkWithCamo(ctx, imgSrcOrigin)
img.Attr[i] = imgAttr
}
if !RenderBehaviorForTesting.DisableAdditionalAttributes && !hasLazy && !strings.HasPrefix(attrSrc, "data:") {
@@ -179,9 +186,24 @@ func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
if attr.Key != "src" {
continue
}
attr.Val = ctx.RenderHelper.ResolveLink(attr.Val, LinkTypeMedia)
attr.Val = camoHandleLink(attr.Val)
attr.Val = resolveMediaLinkWithCamo(ctx, attr.Val)
node.Attr[i] = attr
}
return next
}
func visitNodePictureSource(ctx *RenderContext, node *html.Node) (next *html.Node) {
next = node.NextSibling
for attrIdx, attr := range node.Attr {
if attr.Key != "srcset" {
continue
}
srcsetCandidates := htmlutil.ParseSrcset(attr.Val)
for i, candidate := range srcsetCandidates {
srcsetCandidates[i].SrcLink = resolveMediaLinkWithCamo(ctx, candidate.SrcLink)
}
attr.Val = htmlutil.EncodeSrcset(srcsetCandidates)
node.Attr[attrIdx] = attr
}
return next
}
+1 -1
View File
@@ -42,7 +42,7 @@ func resolveLinkRelative(ctx context.Context, base, cur, link string, absolute b
finalLink += "?" + linkURL.RawQuery
}
if linkURL.Fragment != "" {
finalLink += "#" + linkURL.Fragment
finalLink += "#" + linkURL.EscapedFragment()
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestResolveLinkRelative(t *testing.T) {
assert.Equal(t, "/a/c", resolveLinkRelative(ctx, "/a", "b", "/c", false))
assert.Equal(t, "/a/c#id", resolveLinkRelative(ctx, "/a", "b", "/c#id", false))
assert.Equal(t, "/a/%2f?k=/", resolveLinkRelative(ctx, "/a", "b", "/%2f/?k=/", false))
assert.Equal(t, "/a/b/c?k=v#id", resolveLinkRelative(ctx, "/a", "b", "c/?k=v#id", false))
assert.Equal(t, "/a/b/c?k=v#with%20space", resolveLinkRelative(ctx, "/a", "b", "c/?k=v#with%20space", false))
assert.Equal(t, "%invalid", resolveLinkRelative(ctx, "/a", "b", "%invalid", false))
assert.Equal(t, "http://localhost:3000/a", resolveLinkRelative(ctx, "/a", "", "", true))