diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index fce88b0a4..b28585bd9 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -3729,10 +3729,49 @@ 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" + 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..2033cbea5 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") + { + 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}"