From 1f4be5baad7e17e1ec683da1831c22f2280c48aa Mon Sep 17 00:00:00 2001 From: Marco De Araujo Date: Mon, 18 Nov 2024 21:47:11 +0000 Subject: [PATCH] Escaping specific markdown in commit messages on Discord-type embeds #3664 (#5811) Co-authored-by: Marco De Araujo Co-committed-by: Marco De Araujo --- services/webhook/discord.go | 41 +++++++++++++ services/webhook/discord_test.go | 100 +++++++++++++++++++++++++++++++ services/webhook/general_test.go | 4 ++ 3 files changed, 145 insertions(+) diff --git a/services/webhook/discord.go b/services/webhook/discord.go index 7741ceb10d..cd25175ea1 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -10,6 +10,7 @@ import ( "html/template" "net/http" "net/url" + "regexp" "strconv" "strings" "unicode/utf8" @@ -202,6 +203,9 @@ func (d discordConvertor) Push(p *api.PushPayload) (DiscordPayload, error) { // limit the commit message display to just the summary, otherwise it would be hard to read message := strings.TrimRight(strings.SplitN(commit.Message, "\n", 1)[0], "\r") + // Escaping markdown character + message = escapeMarkdown(message) + // a limit of 50 is set because GitHub does the same if utf8.RuneCountInString(message) > 50 { message = fmt.Sprintf("%.47s...", message) @@ -365,3 +369,40 @@ func (d discordConvertor) createPayload(s *api.User, title, text, url string, co }, } } + +var orderedListPattern = regexp.MustCompile(`(\d+)\.`) + +var markdownPatterns = map[string]*regexp.Regexp{ + "~": regexp.MustCompile(`\~(.*?)\~`), + "*": regexp.MustCompile(`\*(.*?)\*`), + "_": regexp.MustCompile(`\_(.*?)\_`), +} + +var markdownToEscape = strings.NewReplacer( + "* ", "\\* ", + "`", "\\`", + "[", "\\[", + "]", "\\]", + "(", "\\(", + ")", "\\)", + "#", "\\#", + "+ ", "\\+ ", + "- ", "\\- ", + "---", "\\---", + "!", "\\!", + "|", "\\|", + "<", "\\<", + ">", "\\>", +) + +// Escape Markdown characters +func escapeMarkdown(input string) string { + // Escaping ordered list + output := orderedListPattern.ReplaceAllString(input, "$1\\.") + + for char, pattern := range markdownPatterns { + output = pattern.ReplaceAllString(output, fmt.Sprintf(`\%s$1\%s`, char, char)) + } + + return markdownToEscape.Replace(output) +} diff --git a/services/webhook/discord_test.go b/services/webhook/discord_test.go index 680f7806a9..4edd06bd76 100644 --- a/services/webhook/discord_test.go +++ b/services/webhook/discord_test.go @@ -94,6 +94,20 @@ func TestDiscordPayload(t *testing.T) { assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) + t.Run("PushWithMarkdownCharactersInCommitMessage", func(t *testing.T) { + p := pushTestEscapeCommitMessagePayload() + + pl, err := dc.Push(p) + require.NoError(t, err) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo:test] 2 new commits", pl.Embeds[0].Title) + assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) \\# conflicts\n\\# \\- some/conflicting/file.txt - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) \\# conflicts\n\\# \\- some/conflicting/file.txt - user1", pl.Embeds[0].Description) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) + }) + t.Run("Issue", func(t *testing.T) { p := issueTestPayload() @@ -346,3 +360,89 @@ func TestDiscordJSONPayload(t *testing.T) { require.NoError(t, err) assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Embeds[0].Description) } + +var escapedMarkdownTests = map[string]struct { + input string + expected string +}{ + "Escape heading level 1": { + input: "# Heading level 1", + expected: "\\# Heading level 1", + }, + "Escape heading level 2": { + input: "## Heading level 2", + expected: "\\#\\# Heading level 2", + }, + "Escape heading level 3": { + input: "### Heading level 3", + expected: "\\#\\#\\# Heading level 3", + }, + "Escape bold text": { + input: "**bold text**", + expected: "\\*\\*bold text\\*\\*", + }, + "Escape italic text": { + input: "*italic text*", + expected: "\\*italic text\\*", + }, + "Escape italic text underline": { + input: "_italic text_", + expected: "\\_italic text\\_", + }, + "Escape strikethrough": { + input: "~~strikethrough~~", + expected: "\\~\\~strikethrough\\~\\~", + }, + "Escape Ordered list item": { + input: "1. Ordered list item\n2. Second ordered list item\n999999999999. 999999999999 ordered list item", + expected: "1\\. Ordered list item\n2\\. Second ordered list item\n999999999999\\. 999999999999 ordered list item", + }, + "Escape Unordered list item": { + input: "- Unordered list\n + using plus", + expected: "\\- Unordered list\n \\+ using plus", + }, + "Escape bullet list item": { + input: "* Bullet list item", + expected: "\\* Bullet list item", + }, + "Escape table": { + input: "| Table | Example |\n|-|-|\n| Lorem | Ipsum |", + expected: "\\| Table \\| Example \\|\n\\|-\\|-\\|\n\\| Lorem \\| Ipsum \\|", + }, + "Escape link": { + input: "[Link to Forgejo](https://forgejo.org/)", + expected: "\\[Link to Forgejo\\]\\(https://forgejo.org/\\)", + }, + "Escape Alt text for an image": { + input: "![Alt text for an image](https://forgejo.org/_astro/mascot-dark.1omhhgvT_Zm0N2n.webp)", + expected: "\\!\\[Alt text for an image\\]\\(https://forgejo.org/\\_astro/mascot-dark.1omhhgvT\\_Zm0N2n.webp\\)", + }, + "Escape URL if it has markdown character": { + input: "https://forgejo.org/_astro/mascot-dark.1omhhgvT_Zm0N2n.webp", + expected: "https://forgejo.org/\\_astro/mascot-dark.1omhhgvT\\_Zm0N2n.webp", + }, + "Escape blockquote text": { + input: "> Blockquote text.", + expected: "\\> Blockquote text.", + }, + "Escape inline code": { + input: "`Inline code`", + expected: "\\`Inline code\\`", + }, + "Escape multiple code": { + input: "```\nCode block\nwith multiple lines\n```\n", + expected: "\\`\\`\\`\nCode block\nwith multiple lines\n\\`\\`\\`\n", + }, + "Escape horizontal rule": { + input: "---", + expected: "\\---", + }, +} + +func TestEscapeMarkdownChar(t *testing.T) { + for name, test := range escapedMarkdownTests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.expected, escapeMarkdown(test.input)) + }) + } +} diff --git a/services/webhook/general_test.go b/services/webhook/general_test.go index 6dcd787fab..8412293708 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -72,6 +72,10 @@ func pushTestMultilineCommitMessagePayload() *api.PushPayload { return pushTestPayloadWithCommitMessage("This is a commit summary ⚠️⚠️⚠️⚠️ containing 你好 ⚠️⚠️️\n\nThis is the message body.") } +func pushTestEscapeCommitMessagePayload() *api.PushPayload { + return pushTestPayloadWithCommitMessage("# conflicts\n# - some/conflicting/file.txt") +} + func pushTestPayloadWithCommitMessage(message string) *api.PushPayload { commit := &api.PayloadCommit{ ID: "2020558fe2e34debb818a514715839cabd25e778",