diff --git a/models/renderhelper/repo_file_test.go b/models/renderhelper/repo_file_test.go index 2f28d2c9065..12e1275b2b0 100644 --- a/models/renderhelper/repo_file_test.go +++ b/models/renderhelper/repo_file_test.go @@ -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) { `, rendered) }) + + t.Run("PictureSource", func(t *testing.T) { + rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}). + WithMarkupType(markdown.MarkupName) + rendered, err := testRenderString(rctx, ``) + assert.NoError(t, err) + assert.Equal(t, test.NormalizeHTMLSpaces(` +

+ + + + + + +

+`), test.NormalizeHTMLSpaces(rendered)) + }) } func TestRepoFileOrgMode(t *testing.T) { diff --git a/modules/htmlutil/html_srcset.go b/modules/htmlutil/html_srcset.go new file mode 100644 index 00000000000..b9913a06322 --- /dev/null +++ b/modules/htmlutil/html_srcset.go @@ -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() +} diff --git a/modules/htmlutil/html_srcset_test.go b/modules/htmlutil/html_srcset_test.go new file mode 100644 index 00000000000..41122a6a136 --- /dev/null +++ b/modules/htmlutil/html_srcset_test.go @@ -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)) + }) +} diff --git a/modules/markup/camo.go b/modules/markup/camo.go index 6c10dee6129..61b5789afd7 100644 --- a/modules/markup/camo.go +++ b/modules/markup/camo.go @@ -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) } diff --git a/modules/markup/camo_test.go b/modules/markup/camo_test.go index d432b9df4f0..f6afe9ec6e8 100644 --- a/modules/markup/camo_test.go +++ b/modules/markup/camo_test.go @@ -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")) diff --git a/modules/markup/html.go b/modules/markup/html.go index f64114226e1..4731ae91e93 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -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 diff --git a/modules/markup/html_node.go b/modules/markup/html_node.go index 0447b6d8a39..0226616a43a 100644 --- a/modules/markup/html_node.go +++ b/modules/markup/html_node.go @@ -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 +} diff --git a/modules/markup/render_link.go b/modules/markup/render_link.go index c7470cda7e6..273146ac259 100644 --- a/modules/markup/render_link.go +++ b/modules/markup/render_link.go @@ -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() } } diff --git a/modules/markup/render_link_test.go b/modules/markup/render_link_test.go index 1ebe60688a3..57485ebf55a 100644 --- a/modules/markup/render_link_test.go +++ b/modules/markup/render_link_test.go @@ -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))