From 705f6816baac421d006de84dea367f03a53b7d52 Mon Sep 17 00:00:00 2001 From: Naph1 Date: Sat, 16 Aug 2025 15:14:35 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20webhook=E3=81=AE=E3=83=A1=E3=83=83?= =?UTF-8?q?=E3=82=BB=E3=83=BC=E3=82=B8=E7=B7=A8=E9=9B=86API=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/v3-api.yaml | 39 +++++++++++ router/v3/router.go | 6 +- router/v3/webhooks.go | 56 ++++++++++++++++ router/v3/webhooks_test.go | 132 +++++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 1 deletion(-) diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index 4b27ab934..d8ae49583 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -3733,6 +3733,45 @@ paths: parameters: - $ref: "#/components/parameters/webhookIdInPath" - $ref: "#/components/parameters/messageIdInPath" + put: + summary: Webhookの投稿メッセージを編集 + responses: + "204": + description: No Content + "400": + description: Bad Request + "403": + description: Forbidden + "404": + description: Not Found + "415": + description: Unsupported Media Type + operationId: editWebhookMessage + parameters: + - schema: + type: string + in: header + name: X-TRAQ-Signature + description: リクエストボディシグネチャ(Secretが設定されている場合は必須) + - schema: + type: integer + default: 0 + in: query + name: embed + description: メンション・チャンネルリンクを自動埋め込みする場合に1を指定する + requestBody: + content: + text/plain: + schema: + type: string + description: メッセージ文字列 + description: "" + tags: + - webhook + description: |- + Webhookの投稿メッセージを編集します。 + secureなウェブフックに対しては`X-TRAQ-Signature`ヘッダーが必須です。 + アーカイブされているチャンネルのメッセージは編集できません。 delete: summary: Webhookの投稿メッセージを削除 tags: diff --git a/router/v3/router.go b/router/v3/router.go index 00c1b8abb..9cc7b0339 100644 --- a/router/v3/router.go +++ b/router/v3/router.go @@ -417,7 +417,11 @@ func (h *Handlers) Setup(e *echo.Group) { } apiNoAuth.POST("/login", h.Login, noLogin) apiNoAuth.POST("/logout", h.Logout) - apiNoAuth.POST("/webhooks/:webhookID", h.PostWebhook, retrieve.WebhookID()) + apiWebhooks := apiNoAuth.Group("/webhooks/:webhookID", noLogin) + { + apiWebhooks.POST("", h.PostWebhook, retrieve.WebhookID()) + apiWebhooks.PUT("/messages/:messageID", h.EditWebhookMessage, retrieve.WebhookID(), retrieve.MessageID()) + } apiNoAuth.POST("/qall/webhook", h.LiveKitWebhook) apiNoAuthPublic := apiNoAuth.Group("/public") { diff --git a/router/v3/webhooks.go b/router/v3/webhooks.go index 2ca09ddfb..d0cf1caaa 100644 --- a/router/v3/webhooks.go +++ b/router/v3/webhooks.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -247,6 +248,61 @@ func (h *Handlers) GetWebhookMessages(c echo.Context) error { return serveMessages(c, h.MessageManager, req.convertU(w.GetBotUserID())) } +// EditWebhookMessage PUT /webhooks/:webhookID/messages/:messageID +func (h *Handlers) EditWebhookMessage(c echo.Context) error { + w := getParamWebhook(c) + m := getParamMessage(c) + messageID := getParamAsUUID(c, consts.ParamMessageID) + botUserID := w.GetBotUserID() + messageUserID := m.GetUserID() + + if botUserID != messageUserID { + return herror.Forbidden("you are not allowed to edit this message") + } + + // text/plainのみ受け付ける + switch strings.ToLower(c.Request().Header.Get(echo.HeaderContentType)) { + case echo.MIMETextPlain, strings.ToLower(echo.MIMETextPlainCharsetUTF8): + break + default: + return echo.NewHTTPError(http.StatusUnsupportedMediaType) + } + + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return herror.InternalServerError(err) + } + if len(body) == 0 { + return herror.BadRequest("empty body") + } + + // Webhookシークレット確認 + if len(w.GetSecret()) > 0 { + sig, _ := hex.DecodeString(c.Request().Header.Get(consts.HeaderSignature)) + if len(sig) == 0 { + return herror.BadRequest("missing X-TRAQ-Signature header") + } + if subtle.ConstantTimeCompare(hmac.SHA1(body, w.GetSecret()), sig) != 1 { + return herror.BadRequest("X-TRAQ-Signature is wrong") + } + } + + // 埋め込み変換 + if isTrue(c.QueryParam("embed")) { + body = []byte(h.Replacer.Replace(string(body))) + } + + // メッセージ編集 + if err := h.MessageManager.Edit(messageID, string(body)); err != nil { + if errors.Is(err, message.ErrChannelArchived) { + return herror.BadRequest("the channel has been archived") + } + return herror.InternalServerError(err) + } + + return c.NoContent(http.StatusNoContent) +} + // DeleteWebhookMessage DELETE /webhooks/:webhookID/messages/:messageID func (h *Handlers) DeleteWebhookMessage(c echo.Context) error { w := getParamWebhook(c) diff --git a/router/v3/webhooks_test.go b/router/v3/webhooks_test.go index d7f175101..3aa59cc06 100644 --- a/router/v3/webhooks_test.go +++ b/router/v3/webhooks_test.go @@ -542,6 +542,138 @@ func TestHandlers_GetWebhookMessages(t *testing.T) { }) } +func TestHandlers_EditWebhookMessage(t *testing.T) { + t.Parallel() + + path := "/api/v3/webhooks/{webhookId}/messages/{messageId}" + env := Setup(t, common1) + user := env.CreateUser(t, rand) + user2 := env.CreateUser(t, rand) + ch := env.CreateChannel(t, rand) + archived := env.CreateChannel(t, rand) + wh := env.CreateWebhook(t, rand, user.GetID(), ch.ID) + wh2 := env.CreateWebhook(t, rand, user2.GetID(), ch.ID) + archivedMessage := env.CreateMessage(t, wh.GetBotUserID(), archived.ID, "archived") + require.NoError(t, env.CM.ArchiveChannel(archived.ID, user.GetID())) + m := env.CreateMessage(t, wh.GetBotUserID(), ch.ID, "test") + + calcHMACSHA1 := func(t *testing.T, message, secret string) string { + t.Helper() + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh.GetID(), m.GetID()). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "updated", wh.GetSecret())). + WithQuery("embed", 1). + WithText("updated"). + Expect(). + Status(http.StatusNoContent) + + tl, err := env.MM.GetTimeline(message.TimelineQuery{Channel: ch.ID}) + require.NoError(t, err) + if assert.Len(t, tl.Records(), 1) { + got := tl.Records()[0] + assert.EqualValues(t, wh.GetBotUserID(), got.GetUserID()) + assert.EqualValues(t, "updated", got.GetText()) + } + }) + + t.Run("bad request (empty body)", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh.GetID(), m.GetID()). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "", wh.GetSecret())). + WithText(""). + Expect(). + Status(http.StatusBadRequest) + }) + + t.Run("bad request (no signature)", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh.GetID(), m.GetID()). + WithText("updated"). + Expect(). + Status(http.StatusBadRequest) + }) + + t.Run("bad request (bad signature)", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh.GetID(), m.GetID()). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "test", wh.GetSecret())). + WithText("testTestTest"). + Expect(). + Status(http.StatusBadRequest) + }) + + t.Run("bad request (archived)", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh.GetID(), archivedMessage.GetID()). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "xxpoxx", wh.GetSecret())). + WithText("xxpoxx"). + Expect(). + Status(http.StatusBadRequest) + }) + + t.Run("webhook not found", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, uuid.Must(uuid.NewV7()), m.GetID()). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "updated", wh.GetSecret())). + WithText("updated"). + Expect(). + Status(http.StatusNotFound) + }) + + t.Run("message not found", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh.GetID(), uuid.Must(uuid.NewV7())). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "updated", wh.GetSecret())). + WithText("updated"). + Expect(). + Status(http.StatusNotFound) + }) + + t.Run("message and webhook not found", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, uuid.Must(uuid.NewV7()), uuid.Must(uuid.NewV7())). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "updated", wh.GetSecret())). + WithText("updated"). + Expect(). + Status(http.StatusNotFound) + }) + + t.Run("unsupported media type", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh.GetID(), m.GetID()). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "xxpoxx", wh.GetSecret())). + WithJSON(map[string]interface{}{"text": "xxpoxx"}). + Expect(). + Status(http.StatusUnsupportedMediaType) + }) + + t.Run("forbidden (not message owner)", func(t *testing.T) { + t.Parallel() + e := env.R(t) + e.PUT(path, wh2.GetID(), m.GetID()). + WithHeader("X-TRAQ-Signature", calcHMACSHA1(t, "updated", wh2.GetSecret())). + WithText("updated"). + Expect(). + Status(http.StatusForbidden) + }) + +} + func TestHandlers_DeleteWebhookMessage(t *testing.T) { t.Parallel() path := "/api/v3/webhooks/{webhookId}/messages/{messageId}" From 4f7fc277da6b0a7f4c3350e363ea71e34b3bb564 Mon Sep 17 00:00:00 2001 From: Naph1 Date: Sat, 16 Aug 2025 21:12:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20apiWebhooks=E3=81=8B=E3=82=89?= =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AAnoLogin=E5=BC=95=E6=95=B0=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- router/v3/router.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/v3/router.go b/router/v3/router.go index 9cc7b0339..2033cbea5 100644 --- a/router/v3/router.go +++ b/router/v3/router.go @@ -417,7 +417,7 @@ func (h *Handlers) Setup(e *echo.Group) { } apiNoAuth.POST("/login", h.Login, noLogin) apiNoAuth.POST("/logout", h.Logout) - apiWebhooks := apiNoAuth.Group("/webhooks/:webhookID", noLogin) + apiWebhooks := apiNoAuth.Group("/webhooks/:webhookID") { apiWebhooks.POST("", h.PostWebhook, retrieve.WebhookID()) apiWebhooks.PUT("/messages/:messageID", h.EditWebhookMessage, retrieve.WebhookID(), retrieve.MessageID()) From 7728c0e02f48c22f5b55f482577d427996ca9bf9 Mon Sep 17 00:00:00 2001 From: Naph1 Date: Thu, 25 Sep 2025 13:51:24 +0900 Subject: [PATCH 3/4] fix: correct webhook message path parameter syntax in OpenAPI spec --- docs/v3-api.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index d8ae49583..ef621cace 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -3729,7 +3729,7 @@ paths: - $ref: "#/components/parameters/inclusiveInQuery" - $ref: "#/components/parameters/orderInQuery" description: 指定されたWebhookが投稿したメッセージのリストを返します。 - "/webhooks/:webhookID/messages/:messageID": + "/webhooks/:webhookID/messages/{messageId}": parameters: - $ref: "#/components/parameters/webhookIdInPath" - $ref: "#/components/parameters/messageIdInPath" From 9908a77809b040f37decbd198b6146808dbe22b4 Mon Sep 17 00:00:00 2001 From: Naph1 Date: Sun, 28 Sep 2025 14:43:16 +0900 Subject: [PATCH 4/4] fix: align webhook route parameter syntax with OpenAPI spec --- docs/v3-api.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index a3c769fd7..b28585bd9 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -3729,7 +3729,7 @@ paths: - $ref: "#/components/parameters/inclusiveInQuery" - $ref: "#/components/parameters/orderInQuery" description: 指定されたWebhookが投稿したメッセージのリストを返します。 - "/webhooks/:webhookID/messages/{messageId}": + "/webhooks/{webhookId}/messages/{messageId}": parameters: - $ref: "#/components/parameters/webhookIdInPath" - $ref: "#/components/parameters/messageIdInPath"