Support relative paths to videos from Wiki pages (#31061)

This change fixes cases when a Wiki page refers to a video stored in the
Wiki repository using relative path. It follows the similar case which
has been already implemented for images.

Test plan:
- Create repository and Wiki page
- Clone the Wiki repository
- Add video to it, say `video.mp4`
- Modify the markdown file to refer to the video using `<video
src="video.mp4">`
- Commit the Wiki page
- Observe that the video is properly displayed

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Sergey Sharybin 2024-06-21 20:23:54 +02:00 committed by GitHub
parent 996037fb6a
commit 49b8716c40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 83 additions and 43 deletions

View File

@ -88,6 +88,10 @@ func IsFullURLString(link string) bool {
return fullURLPattern.MatchString(link) return fullURLPattern.MatchString(link)
} }
func IsNonEmptyRelativePath(link string) bool {
return link != "" && !IsFullURLString(link) && link[0] != '/' && link[0] != '?' && link[0] != '#'
}
// regexp for full links to issues/pulls // regexp for full links to issues/pulls
var issueFullPattern *regexp.Regexp var issueFullPattern *regexp.Regexp
@ -358,41 +362,6 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output
return nil return nil
} }
func handleNodeImg(ctx *RenderContext, img *html.Node) {
for i, attr := range img.Attr {
if attr.Key != "src" {
continue
}
if attr.Val != "" && !IsFullURLString(attr.Val) && !strings.HasPrefix(attr.Val, "/") {
attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val)
// By default, the "<img>" tag should also be clickable,
// because frontend use `<img>` to paste the re-scaled image into the markdown,
// so it must match the default markdown image behavior.
hasParentAnchor := false
for p := img.Parent; p != nil; p = p.Parent {
if hasParentAnchor = p.Type == html.ElementNode && p.Data == "a"; hasParentAnchor {
break
}
}
if !hasParentAnchor {
imgA := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{
{Key: "href", Val: attr.Val},
{Key: "target", Val: "_blank"},
}}
parent := img.Parent
imgNext := img.NextSibling
parent.RemoveChild(img)
parent.InsertBefore(imgA, imgNext)
imgA.AppendChild(img)
}
}
attr.Val = camoHandleLink(attr.Val)
img.Attr[i] = attr
}
}
func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Node { func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Node {
// Add user-content- to IDs and "#" links if they don't already have them // Add user-content- to IDs and "#" links if they don't already have them
for idx, attr := range node.Attr { for idx, attr := range node.Attr {
@ -412,20 +381,20 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
} }
} }
// We ignore code and pre.
switch node.Type { switch node.Type {
case html.TextNode: case html.TextNode:
processTextNodes(ctx, procs, node) processTextNodes(ctx, procs, node)
case html.ElementNode: case html.ElementNode:
if node.Data == "img" { if node.Data == "code" || node.Data == "pre" {
next := node.NextSibling // ignore code and pre nodes
handleNodeImg(ctx, node) return node.NextSibling
return next } else if node.Data == "img" {
return visitNodeImg(ctx, node)
} else if node.Data == "video" {
return visitNodeVideo(ctx, node)
} else if node.Data == "a" { } else if node.Data == "a" {
// Restrict text in links to emojis // Restrict text in links to emojis
procs = emojiProcessors procs = emojiProcessors
} else if node.Data == "code" || node.Data == "pre" {
return node.NextSibling
} else if node.Data == "i" { } else if node.Data == "i" {
for _, attr := range node.Attr { for _, attr := range node.Attr {
if attr.Key != "class" { if attr.Key != "class" {

View File

@ -0,0 +1,62 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
)
func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
next = img.NextSibling
for i, attr := range img.Attr {
if attr.Key != "src" {
continue
}
if IsNonEmptyRelativePath(attr.Val) {
attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val)
// By default, the "<img>" tag should also be clickable,
// because frontend use `<img>` to paste the re-scaled image into the markdown,
// so it must match the default markdown image behavior.
hasParentAnchor := false
for p := img.Parent; p != nil; p = p.Parent {
if hasParentAnchor = p.Type == html.ElementNode && p.Data == "a"; hasParentAnchor {
break
}
}
if !hasParentAnchor {
imgA := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{
{Key: "href", Val: attr.Val},
{Key: "target", Val: "_blank"},
}}
parent := img.Parent
imgNext := img.NextSibling
parent.RemoveChild(img)
parent.InsertBefore(imgA, imgNext)
imgA.AppendChild(img)
}
}
attr.Val = camoHandleLink(attr.Val)
img.Attr[i] = attr
}
return next
}
func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
next = node.NextSibling
for i, attr := range node.Attr {
if attr.Key != "src" {
continue
}
if IsNonEmptyRelativePath(attr.Val) {
attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val)
}
attr.Val = camoHandleLink(attr.Val)
node.Attr[i] = attr
}
return next
}

View File

@ -522,7 +522,7 @@ func TestRender_ShortLinks(t *testing.T) {
`<p><a href="https://example.org" rel="nofollow">[[foobar]]</a></p>`) `<p><a href="https://example.org" rel="nofollow">[[foobar]]</a></p>`)
} }
func TestRender_RelativeImages(t *testing.T) { func TestRender_RelativeMedias(t *testing.T) {
render := func(input string, isWiki bool, links markup.Links) string { render := func(input string, isWiki bool, links markup.Links) string {
buffer, err := markdown.RenderString(&markup.RenderContext{ buffer, err := markdown.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
@ -548,6 +548,15 @@ func TestRender_RelativeImages(t *testing.T) {
out = render(`<img src="/LINK">`, true, markup.Links{Base: "/test-owner/test-repo", BranchPath: "test-branch"}) out = render(`<img src="/LINK">`, true, markup.Links{Base: "/test-owner/test-repo", BranchPath: "test-branch"})
assert.Equal(t, `<img src="/LINK"/>`, out) assert.Equal(t, `<img src="/LINK"/>`, out)
out = render(`<video src="LINK">`, false, markup.Links{Base: "/test-owner/test-repo"})
assert.Equal(t, `<video src="/test-owner/test-repo/LINK"></video>`, out)
out = render(`<video src="LINK">`, true, markup.Links{Base: "/test-owner/test-repo"})
assert.Equal(t, `<video src="/test-owner/test-repo/wiki/raw/LINK"></video>`, out)
out = render(`<video src="/LINK">`, false, markup.Links{Base: "/test-owner/test-repo"})
assert.Equal(t, `<video src="/LINK"></video>`, out)
} }
func Test_ParseClusterFuzz(t *testing.T) { func Test_ParseClusterFuzz(t *testing.T) {