Skip to content

Commit 828a27f

Browse files
S7evinKlafrikstulir
authored
Add Matrix webhook (#10831)
* Add Matrix webhook Signed-off-by: Till Faelligen <[email protected]> * Add template and related translations for Matrix hook Signed-off-by: Till Faelligen <[email protected]> * Add actual webhook routes and form Signed-off-by: Till Faelligen <[email protected]> * Add missing file Signed-off-by: Till Faelligen <[email protected]> * Update modules/webhook/matrix_test.go * Use stricter regex to replace URLs Signed-off-by: Till Faelligen <[email protected]> * Escape url and text Signed-off-by: Till Faelligen <[email protected]> * Remove unnecessary whitespace * Fix copy and paste mistake Co-Authored-By: Tulir Asokan <[email protected]> * Fix indention inconsistency * Use Authorization header instead of url parameter * Add raw commit information to webhook Co-authored-by: Lauris BH <[email protected]> Co-authored-by: Tulir Asokan <[email protected]>
1 parent 7cd4704 commit 828a27f

File tree

14 files changed

+645
-1
lines changed

14 files changed

+645
-1
lines changed

models/webhook.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ const (
559559
TELEGRAM
560560
MSTEAMS
561561
FEISHU
562+
MATRIX
562563
)
563564

564565
var hookTaskTypes = map[string]HookTaskType{
@@ -570,6 +571,7 @@ var hookTaskTypes = map[string]HookTaskType{
570571
"telegram": TELEGRAM,
571572
"msteams": MSTEAMS,
572573
"feishu": FEISHU,
574+
"matrix": MATRIX,
573575
}
574576

575577
// ToHookTaskType returns HookTaskType by given name.
@@ -596,6 +598,8 @@ func (t HookTaskType) Name() string {
596598
return "msteams"
597599
case FEISHU:
598600
return "feishu"
601+
case MATRIX:
602+
return "matrix"
599603
}
600604
return ""
601605
}

modules/auth/repo_form.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,20 @@ func (f *NewTelegramHookForm) Validate(ctx *macaron.Context, errs binding.Errors
313313
return validate(errs, ctx.Data, f, ctx.Locale)
314314
}
315315

316+
// NewMatrixHookForm form for creating Matrix hook
317+
type NewMatrixHookForm struct {
318+
HomeserverURL string `binding:"Required;ValidUrl"`
319+
RoomID string `binding:"Required"`
320+
AccessToken string `binding:"Required"`
321+
MessageType int
322+
WebhookForm
323+
}
324+
325+
// Validate validates the fields
326+
func (f *NewMatrixHookForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
327+
return validate(errs, ctx.Data, f, ctx.Locale)
328+
}
329+
316330
// NewMSTeamsHookForm form for creating MS Teams hook
317331
type NewMSTeamsHookForm struct {
318332
PayloadURL string `binding:"Required;ValidUrl"`

modules/setting/webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func newWebhookService() {
3636
Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000)
3737
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
3838
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
39-
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu"}
39+
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix"}
4040
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
4141
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
4242
if Webhook.ProxyURL != "" {

modules/webhook/deliver.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ func Deliver(t *models.HookTask) error {
7373
return fmt.Errorf("Invalid http method for webhook: [%d] %v", t.ID, t.HTTPMethod)
7474
}
7575

76+
if t.Type == models.MATRIX {
77+
req, err = getMatrixHookRequest(t)
78+
if err != nil {
79+
return err
80+
}
81+
}
82+
7683
req.Header.Add("X-Gitea-Delivery", t.UUID)
7784
req.Header.Add("X-Gitea-Event", t.EventType.Event())
7885
req.Header.Add("X-Gitea-Signature", t.Signature)

modules/webhook/matrix.go

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
// Copyright 2020 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package webhook
6+
7+
import (
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"html"
12+
"net/http"
13+
"regexp"
14+
"strings"
15+
16+
"code.gitea.io/gitea/models"
17+
"code.gitea.io/gitea/modules/git"
18+
"code.gitea.io/gitea/modules/log"
19+
"code.gitea.io/gitea/modules/setting"
20+
api "code.gitea.io/gitea/modules/structs"
21+
)
22+
23+
const matrixPayloadSizeLimit = 1024 * 64
24+
25+
// MatrixMeta contains the Matrix metadata
26+
type MatrixMeta struct {
27+
HomeserverURL string `json:"homeserver_url"`
28+
Room string `json:"room_id"`
29+
AccessToken string `json:"access_token"`
30+
MessageType int `json:"message_type"`
31+
}
32+
33+
var messageTypeText = map[int]string{
34+
1: "m.notice",
35+
2: "m.text",
36+
}
37+
38+
// GetMatrixHook returns Matrix metadata
39+
func GetMatrixHook(w *models.Webhook) *MatrixMeta {
40+
s := &MatrixMeta{}
41+
if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
42+
log.Error("webhook.GetMatrixHook(%d): %v", w.ID, err)
43+
}
44+
return s
45+
}
46+
47+
// MatrixPayloadUnsafe contains the (unsafe) payload for a Matrix room
48+
type MatrixPayloadUnsafe struct {
49+
MatrixPayloadSafe
50+
AccessToken string `json:"access_token"`
51+
}
52+
53+
// safePayload "converts" a unsafe payload to a safe payload
54+
func (p *MatrixPayloadUnsafe) safePayload() *MatrixPayloadSafe {
55+
return &MatrixPayloadSafe{
56+
Body: p.Body,
57+
MsgType: p.MsgType,
58+
Format: p.Format,
59+
FormattedBody: p.FormattedBody,
60+
Commits: p.Commits,
61+
}
62+
}
63+
64+
// MatrixPayloadSafe contains (safe) payload for a Matrix room
65+
type MatrixPayloadSafe struct {
66+
Body string `json:"body"`
67+
MsgType string `json:"msgtype"`
68+
Format string `json:"format"`
69+
FormattedBody string `json:"formatted_body"`
70+
Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"`
71+
}
72+
73+
// SetSecret sets the Matrix secret
74+
func (p *MatrixPayloadUnsafe) SetSecret(_ string) {}
75+
76+
// JSONPayload Marshals the MatrixPayloadUnsafe to json
77+
func (p *MatrixPayloadUnsafe) JSONPayload() ([]byte, error) {
78+
data, err := json.MarshalIndent(p, "", " ")
79+
if err != nil {
80+
return []byte{}, err
81+
}
82+
return data, nil
83+
}
84+
85+
// MatrixLinkFormatter creates a link compatible with Matrix
86+
func MatrixLinkFormatter(url string, text string) string {
87+
return fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(url), html.EscapeString(text))
88+
}
89+
90+
// MatrixLinkToRef Matrix-formatter link to a repo ref
91+
func MatrixLinkToRef(repoURL, ref string) string {
92+
refName := git.RefEndName(ref)
93+
switch {
94+
case strings.HasPrefix(ref, git.BranchPrefix):
95+
return MatrixLinkFormatter(repoURL+"/src/branch/"+refName, refName)
96+
case strings.HasPrefix(ref, git.TagPrefix):
97+
return MatrixLinkFormatter(repoURL+"/src/tag/"+refName, refName)
98+
default:
99+
return MatrixLinkFormatter(repoURL+"/src/commit/"+refName, refName)
100+
}
101+
}
102+
103+
func getMatrixCreatePayload(p *api.CreatePayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
104+
repoLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
105+
refLink := MatrixLinkToRef(p.Repo.HTMLURL, p.Ref)
106+
text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName)
107+
108+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
109+
}
110+
111+
// getMatrixDeletePayload composes Matrix payload for delete a branch or tag.
112+
func getMatrixDeletePayload(p *api.DeletePayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
113+
refName := git.RefEndName(p.Ref)
114+
repoLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
115+
text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName)
116+
117+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
118+
}
119+
120+
// getMatrixForkPayload composes Matrix payload for forked by a repository.
121+
func getMatrixForkPayload(p *api.ForkPayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
122+
baseLink := MatrixLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName)
123+
forkLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
124+
text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink)
125+
126+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
127+
}
128+
129+
func getMatrixIssuesPayload(p *api.IssuePayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
130+
text, _, _, _ := getIssuesPayloadInfo(p, MatrixLinkFormatter, true)
131+
132+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
133+
}
134+
135+
func getMatrixIssueCommentPayload(p *api.IssueCommentPayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
136+
text, _, _ := getIssueCommentPayloadInfo(p, MatrixLinkFormatter, true)
137+
138+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
139+
}
140+
141+
func getMatrixReleasePayload(p *api.ReleasePayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
142+
text, _ := getReleasePayloadInfo(p, MatrixLinkFormatter, true)
143+
144+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
145+
}
146+
147+
func getMatrixPushPayload(p *api.PushPayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
148+
var commitDesc string
149+
150+
if len(p.Commits) == 1 {
151+
commitDesc = "1 commit"
152+
} else {
153+
commitDesc = fmt.Sprintf("%d commits", len(p.Commits))
154+
}
155+
156+
repoLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
157+
branchLink := MatrixLinkToRef(p.Repo.HTMLURL, p.Ref)
158+
text := fmt.Sprintf("[%s] %s pushed %s to %s:<br>", repoLink, p.Pusher.UserName, commitDesc, branchLink)
159+
160+
// for each commit, generate a new line text
161+
for i, commit := range p.Commits {
162+
text += fmt.Sprintf("%s: %s - %s", MatrixLinkFormatter(commit.URL, commit.ID[:7]), commit.Message, commit.Author.Name)
163+
// add linebreak to each commit but the last
164+
if i < len(p.Commits)-1 {
165+
text += "<br>"
166+
}
167+
168+
}
169+
170+
return getMatrixPayloadUnsafe(text, p.Commits, matrix), nil
171+
}
172+
173+
func getMatrixPullRequestPayload(p *api.PullRequestPayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
174+
text, _, _, _ := getPullRequestPayloadInfo(p, MatrixLinkFormatter, true)
175+
176+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
177+
}
178+
179+
func getMatrixPullRequestApprovalPayload(p *api.PullRequestPayload, matrix *MatrixMeta, event models.HookEventType) (*MatrixPayloadUnsafe, error) {
180+
senderLink := MatrixLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
181+
title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)
182+
titleLink := fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index)
183+
repoLink := MatrixLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
184+
var text string
185+
186+
switch p.Action {
187+
case api.HookIssueReviewed:
188+
action, err := parseHookPullRequestEventType(event)
189+
if err != nil {
190+
return nil, err
191+
}
192+
193+
text = fmt.Sprintf("[%s] Pull request review %s: [%s](%s) by %s", repoLink, action, title, titleLink, senderLink)
194+
}
195+
196+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
197+
}
198+
199+
func getMatrixRepositoryPayload(p *api.RepositoryPayload, matrix *MatrixMeta) (*MatrixPayloadUnsafe, error) {
200+
senderLink := MatrixLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
201+
repoLink := MatrixLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
202+
var text string
203+
204+
switch p.Action {
205+
case api.HookRepoCreated:
206+
text = fmt.Sprintf("[%s] Repository created by %s", repoLink, senderLink)
207+
case api.HookRepoDeleted:
208+
text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink)
209+
}
210+
211+
return getMatrixPayloadUnsafe(text, nil, matrix), nil
212+
}
213+
214+
// GetMatrixPayload converts a Matrix webhook into a MatrixPayloadUnsafe
215+
func GetMatrixPayload(p api.Payloader, event models.HookEventType, meta string) (*MatrixPayloadUnsafe, error) {
216+
s := new(MatrixPayloadUnsafe)
217+
218+
matrix := &MatrixMeta{}
219+
if err := json.Unmarshal([]byte(meta), &matrix); err != nil {
220+
return s, errors.New("GetMatrixPayload meta json:" + err.Error())
221+
}
222+
223+
switch event {
224+
case models.HookEventCreate:
225+
return getMatrixCreatePayload(p.(*api.CreatePayload), matrix)
226+
case models.HookEventDelete:
227+
return getMatrixDeletePayload(p.(*api.DeletePayload), matrix)
228+
case models.HookEventFork:
229+
return getMatrixForkPayload(p.(*api.ForkPayload), matrix)
230+
case models.HookEventIssues, models.HookEventIssueAssign, models.HookEventIssueLabel, models.HookEventIssueMilestone:
231+
return getMatrixIssuesPayload(p.(*api.IssuePayload), matrix)
232+
case models.HookEventIssueComment, models.HookEventPullRequestComment:
233+
return getMatrixIssueCommentPayload(p.(*api.IssueCommentPayload), matrix)
234+
case models.HookEventPush:
235+
return getMatrixPushPayload(p.(*api.PushPayload), matrix)
236+
case models.HookEventPullRequest, models.HookEventPullRequestAssign, models.HookEventPullRequestLabel,
237+
models.HookEventPullRequestMilestone, models.HookEventPullRequestSync:
238+
return getMatrixPullRequestPayload(p.(*api.PullRequestPayload), matrix)
239+
case models.HookEventPullRequestReviewRejected, models.HookEventPullRequestReviewApproved, models.HookEventPullRequestReviewComment:
240+
return getMatrixPullRequestApprovalPayload(p.(*api.PullRequestPayload), matrix, event)
241+
case models.HookEventRepository:
242+
return getMatrixRepositoryPayload(p.(*api.RepositoryPayload), matrix)
243+
case models.HookEventRelease:
244+
return getMatrixReleasePayload(p.(*api.ReleasePayload), matrix)
245+
}
246+
247+
return s, nil
248+
}
249+
250+
func getMatrixPayloadUnsafe(text string, commits []*api.PayloadCommit, matrix *MatrixMeta) *MatrixPayloadUnsafe {
251+
p := MatrixPayloadUnsafe{}
252+
p.AccessToken = matrix.AccessToken
253+
p.FormattedBody = text
254+
p.Body = getMessageBody(text)
255+
p.Format = "org.matrix.custom.html"
256+
p.MsgType = messageTypeText[matrix.MessageType]
257+
p.Commits = commits
258+
return &p
259+
}
260+
261+
var urlRegex = regexp.MustCompile(`<a [^>]*?href="([^">]*?)">(.*?)</a>`)
262+
263+
func getMessageBody(htmlText string) string {
264+
htmlText = urlRegex.ReplaceAllString(htmlText, "[$2]($1)")
265+
htmlText = strings.ReplaceAll(htmlText, "<br>", "\n")
266+
return htmlText
267+
}
268+
269+
// getMatrixHookRequest creates a new request which contains an Authorization header.
270+
// The access_token is removed from t.PayloadContent
271+
func getMatrixHookRequest(t *models.HookTask) (*http.Request, error) {
272+
payloadunsafe := MatrixPayloadUnsafe{}
273+
if err := json.Unmarshal([]byte(t.PayloadContent), &payloadunsafe); err != nil {
274+
log.Error("Matrix Hook delivery failed: %v", err)
275+
return nil, err
276+
}
277+
278+
payloadsafe := payloadunsafe.safePayload()
279+
280+
var payload []byte
281+
var err error
282+
if payload, err = json.MarshalIndent(payloadsafe, "", " "); err != nil {
283+
return nil, err
284+
}
285+
if len(payload) >= matrixPayloadSizeLimit {
286+
return nil, fmt.Errorf("getMatrixHookRequest: payload size %d > %d", len(payload), matrixPayloadSizeLimit)
287+
}
288+
t.PayloadContent = string(payload)
289+
290+
req, err := http.NewRequest("POST", t.URL, strings.NewReader(string(payload)))
291+
if err != nil {
292+
return nil, err
293+
}
294+
295+
req.Header.Set("Content-Type", "application/json")
296+
req.Header.Add("Authorization", "Bearer "+payloadunsafe.AccessToken)
297+
298+
return req, nil
299+
}

0 commit comments

Comments
 (0)