feat: add optional webhook signing secret (Standard Webhooks HMAC-SHA256) (#6013)

This commit is contained in:
Yiges.M.x.
2026-06-09 22:45:01 +08:00
committed by GitHub
parent 1052c04d33
commit 063a44498d
13 changed files with 215 additions and 30 deletions
+15
View File
@@ -81,3 +81,18 @@ func ValidateURL(rawURL string) error {
}
return nil
}
// ValidateSigningSecret checks that secret is either empty (allowed) or contains
// only printable ASCII characters (0x200x7E), excluding all control characters
// such as \r and \n, which would break the HTTP Authorization header.
func ValidateSigningSecret(secret string) error {
if secret == "" {
return nil
}
for _, r := range secret {
if r < 0x20 || r > 0x7E {
return status.Errorf(codes.InvalidArgument, "signing secret contains invalid character")
}
}
return nil
}
+30
View File
@@ -3,13 +3,19 @@ package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/pkg/errors"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
@@ -78,6 +84,8 @@ type WebhookRequestPayload struct {
Creator string `json:"creator"`
// The memo that triggered this webhook (if applicable).
Memo *v1pb.Memo `json:"memo"`
// Optional signing secret for HMAC-SHA256 signature. Not serialized to JSON.
SigningSecret string `json:"-"`
}
// Post posts the message to webhook endpoint.
@@ -93,6 +101,28 @@ func Post(requestPayload *WebhookRequestPayload) error {
}
req.Header.Set("Content-Type", "application/json")
if requestPayload.SigningSecret != "" {
msgID := "msg_" + uuid.New().String()
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
key := []byte(requestPayload.SigningSecret)
if strings.HasPrefix(requestPayload.SigningSecret, "whsec_") {
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(requestPayload.SigningSecret, "whsec_")); err == nil {
key = decoded
}
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(msgID + "." + timestamp + "."))
mac.Write(body)
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
req.Header.Set("webhook-id", msgID)
req.Header.Set("webhook-timestamp", timestamp)
req.Header.Set("webhook-signature", "v1,"+signature)
}
resp, err := safeClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to post webhook to %s", requestPayload.URL)
+4
View File
@@ -710,6 +710,10 @@ message UserWebhook {
// The last update time of the webhook.
google.protobuf.Timestamp update_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
// Optional. Signing secret used to HMAC-SHA256 sign the webhook request body.
// This field is input-only; it is never returned in responses.
string signing_secret = 6 [(google.api.field_behavior) = INPUT_ONLY];
}
message ListUserWebhooksRequest {
+26 -15
View File
@@ -2162,7 +2162,10 @@ type UserWebhook struct {
// The creation time of the webhook.
CreateTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
// The last update time of the webhook.
UpdateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty"`
UpdateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty"`
// Optional. Signing secret used to HMAC-SHA256 sign the webhook request body.
// This field is input-only; it is never returned in responses.
SigningSecret string `protobuf:"bytes,6,opt,name=signing_secret,json=signingSecret,proto3" json:"signing_secret,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2232,6 +2235,13 @@ func (x *UserWebhook) GetUpdateTime() *timestamppb.Timestamp {
return nil
}
func (x *UserWebhook) GetSigningSecret() string {
if x != nil {
return x.SigningSecret
}
return ""
}
type ListUserWebhooksRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The parent user resource.
@@ -2848,7 +2858,7 @@ type UserStats_MemoTypeStats struct {
func (x *UserStats_MemoTypeStats) Reset() {
*x = UserStats_MemoTypeStats{}
mi := &file_api_v1_user_service_proto_msgTypes[41]
mi := &file_api_v1_user_service_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2860,7 +2870,7 @@ func (x *UserStats_MemoTypeStats) String() string {
func (*UserStats_MemoTypeStats) ProtoMessage() {}
func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_user_service_proto_msgTypes[41]
mi := &file_api_v1_user_service_proto_msgTypes[42]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2873,7 +2883,7 @@ func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserStats_MemoTypeStats.ProtoReflect.Descriptor instead.
func (*UserStats_MemoTypeStats) Descriptor() ([]byte, []int) {
return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 0}
return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 1}
}
func (x *UserStats_MemoTypeStats) GetLinkCount() int32 {
@@ -3334,7 +3344,10 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x17memo_created_timestamps\x18\a \x03(\v2\x1a.google.protobuf.TimestampR\x15memoCreatedTimestamps\x12R\n" +
"\x17memo_updated_timestamps\x18\b \x03(\v2\x1a.google.protobuf.TimestampR\x15memoUpdatedTimestamps\x12!\n" +
"\fpinned_memos\x18\x05 \x03(\tR\vpinnedMemos\x12(\n" +
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a\x8b\x01\n" +
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a;\n" +
"\rTagCountEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\x1a\x8b\x01\n" +
"\rMemoTypeStats\x12\x1d\n" +
"\n" +
"link_count\x18\x01 \x01(\x05R\tlinkCount\x12\x1d\n" +
@@ -3343,10 +3356,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\n" +
"todo_count\x18\x03 \x01(\x05R\ttodoCount\x12\x1d\n" +
"\n" +
"undo_count\x18\x04 \x01(\x05R\tundoCount\x1a;\n" +
"\rTagCountEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01:?\xeaA<\n" +
"undo_count\x18\x04 \x01(\x05R\tundoCount:?\xeaA<\n" +
"\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStatsJ\x04\b\x02\x10\x03R\x17memo_display_timestamps\"D\n" +
"\x13GetUserStatsRequest\x12-\n" +
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
@@ -3457,7 +3467,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x05token\x18\x02 \x01(\tR\x05token\"`\n" +
" DeletePersonalAccessTokenRequest\x12<\n" +
"\x04name\x18\x01 \x01(\tB(\xe0A\x02\xfaA\"\n" +
" memos.api.v1/PersonalAccessTokenR\x04name\"\xda\x01\n" +
" memos.api.v1/PersonalAccessTokenR\x04name\"\x86\x02\n" +
"\vUserWebhook\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" +
"\x03url\x18\x02 \x01(\tR\x03url\x12!\n" +
@@ -3465,7 +3475,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\vcreate_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
"createTime\x12@\n" +
"\vupdate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
"updateTime\"6\n" +
"updateTime\x12*\n" +
"\x0esigning_secret\x18\x06 \x01(\tB\x03\xe0A\x04R\rsigningSecret\"6\n" +
"\x17ListUserWebhooksRequest\x12\x1b\n" +
"\x06parent\x18\x01 \x01(\tB\x03\xe0A\x02R\x06parent\"Q\n" +
"\x18ListUserWebhooksResponse\x125\n" +
@@ -3620,8 +3631,8 @@ var file_api_v1_user_service_proto_goTypes = []any{
(*ListUserNotificationsResponse)(nil), // 42: memos.api.v1.ListUserNotificationsResponse
(*UpdateUserNotificationRequest)(nil), // 43: memos.api.v1.UpdateUserNotificationRequest
(*DeleteUserNotificationRequest)(nil), // 44: memos.api.v1.DeleteUserNotificationRequest
(*UserStats_MemoTypeStats)(nil), // 45: memos.api.v1.UserStats.MemoTypeStats
nil, // 46: memos.api.v1.UserStats.TagCountEntry
nil, // 45: memos.api.v1.UserStats.TagCountEntry
(*UserStats_MemoTypeStats)(nil), // 46: memos.api.v1.UserStats.MemoTypeStats
(*UserSetting_GeneralSetting)(nil), // 47: memos.api.v1.UserSetting.GeneralSetting
(*UserSetting_TagMetadata)(nil), // 48: memos.api.v1.UserSetting.TagMetadata
(*UserSetting_TagsSetting)(nil), // 49: memos.api.v1.UserSetting.TagsSetting
@@ -3646,8 +3657,8 @@ var file_api_v1_user_service_proto_depIdxs = []int32{
4, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User
4, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
56, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
45, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
46, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
46, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
45, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
55, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
55, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
54, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State
+6
View File
@@ -3973,6 +3973,12 @@ components:
type: string
description: The last update time of the webhook.
format: date-time
signingSecret:
writeOnly: true
type: string
description: |-
Optional. Signing secret used to HMAC-SHA256 sign the webhook request body.
This field is input-only; it is never returned in responses.
description: UserWebhook represents a webhook owned by a user.
tags:
- name: AIService
+14 -4
View File
@@ -914,7 +914,9 @@ type WebhooksUserSetting_Webhook struct {
// Descriptive title for the webhook
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
// The webhook URL endpoint
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
// Optional signing secret for webhook authentication.
SigningSecret string `protobuf:"bytes,4,opt,name=signing_secret,json=signingSecret,proto3" json:"signing_secret,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -970,6 +972,13 @@ func (x *WebhooksUserSetting_Webhook) GetUrl() string {
return ""
}
func (x *WebhooksUserSetting_Webhook) GetSigningSecret() string {
if x != nil {
return x.SigningSecret
}
return ""
}
var File_store_user_setting_proto protoreflect.FileDescriptor
const file_store_user_setting_proto_rawDesc = "" +
@@ -1045,13 +1054,14 @@ const file_store_user_setting_proto_rawDesc = "" +
"\bShortcut\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\x16\n" +
"\x06filter\x18\x03 \x01(\tR\x06filter\"\x9e\x01\n" +
"\x06filter\x18\x03 \x01(\tR\x06filter\"\xc5\x01\n" +
"\x13WebhooksUserSetting\x12D\n" +
"\bwebhooks\x18\x01 \x03(\v2(.memos.store.WebhooksUserSetting.WebhookR\bwebhooks\x1aA\n" +
"\bwebhooks\x18\x01 \x03(\v2(.memos.store.WebhooksUserSetting.WebhookR\bwebhooks\x1ah\n" +
"\aWebhook\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\x10\n" +
"\x03url\x18\x03 \x01(\tR\x03urlB\x9b\x01\n" +
"\x03url\x18\x03 \x01(\tR\x03url\x12%\n" +
"\x0esigning_secret\x18\x04 \x01(\tR\rsigningSecretB\x9b\x01\n" +
"\x0fcom.memos.storeB\x10UserSettingProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3"
var (
+2
View File
@@ -128,6 +128,8 @@ message WebhooksUserSetting {
string title = 2;
// The webhook URL endpoint
string url = 3;
// Optional signing secret for webhook authentication.
string signing_secret = 4;
}
repeated Webhook webhooks = 1;
}
+2
View File
@@ -938,6 +938,7 @@ func (s *APIV1Service) DispatchMemoCommentCreatedWebhook(ctx context.Context, co
}
payload.ActivityType = "memos.memo.comment.created"
payload.URL = hook.Url
payload.SigningSecret = hook.SigningSecret
webhook.PostAsync(payload)
}
return nil
@@ -963,6 +964,7 @@ func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1p
}
payload.ActivityType = activityType
payload.URL = hook.Url
payload.SigningSecret = hook.SigningSecret
// Use asynchronous webhook dispatch
webhook.PostAsync(payload)
+24 -6
View File
@@ -1095,12 +1095,16 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea
if err := webhook.ValidateURL(strings.TrimSpace(request.Webhook.Url)); err != nil {
return nil, err
}
if err := webhook.ValidateSigningSecret(strings.TrimSpace(request.Webhook.SigningSecret)); err != nil {
return nil, err
}
webhookID := generateUserWebhookID()
webhook := &storepb.WebhooksUserSetting_Webhook{
Id: webhookID,
Title: request.Webhook.DisplayName,
Url: strings.TrimSpace(request.Webhook.Url),
Id: webhookID,
Title: request.Webhook.DisplayName,
Url: strings.TrimSpace(request.Webhook.Url),
SigningSecret: strings.TrimSpace(request.Webhook.SigningSecret),
}
err = s.Store.AddUserWebhook(ctx, userID, webhook)
@@ -1154,9 +1158,10 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda
// Update the webhook
updatedWebhook := &storepb.WebhooksUserSetting_Webhook{
Id: webhookID,
Title: targetWebhook.Title,
Url: targetWebhook.Url,
Id: webhookID,
Title: targetWebhook.Title,
Url: targetWebhook.Url,
SigningSecret: targetWebhook.SigningSecret,
}
if request.UpdateMask != nil {
@@ -1172,6 +1177,12 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda
}
case "display_name":
updatedWebhook.Title = request.Webhook.DisplayName
case "signing_secret":
secret := strings.TrimSpace(request.Webhook.SigningSecret)
if err := webhook.ValidateSigningSecret(secret); err != nil {
return nil, err
}
updatedWebhook.SigningSecret = secret
default:
// Ignore unsupported fields
}
@@ -1186,6 +1197,13 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda
updatedWebhook.Url = trimmed
}
updatedWebhook.Title = request.Webhook.DisplayName
if request.Webhook.SigningSecret != "" {
secret := strings.TrimSpace(request.Webhook.SigningSecret)
if err := webhook.ValidateSigningSecret(secret); err != nil {
return nil, err
}
updatedWebhook.SigningSecret = secret
}
}
err = s.Store.UpdateUserWebhook(ctx, userID, updatedWebhook)
+73 -4
View File
@@ -1,5 +1,7 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import copy from "copy-to-clipboard";
import { CheckIcon, CopyIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
@@ -22,22 +24,23 @@ interface Props {
interface State {
displayName: string;
url: string;
signingSecret: string | undefined;
}
function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Props) {
const t = useTranslate();
const currentUser = useCurrentUser();
const isCreating = webhookName === undefined;
const [state, setState] = useState<State>({
displayName: "",
url: "",
signingSecret: isCreating ? "" : undefined,
});
const requestState = useLoading(false);
const isCreating = webhookName === undefined;
const [secretCopied, setSecretCopied] = useState(false);
useEffect(() => {
if (webhookName && currentUser) {
// For editing, we need to get the webhook data
// Since we're using user webhooks now, we need to list all webhooks and find the one we want
userServiceClient
.listUserWebhooks({
parent: currentUser.name,
@@ -48,12 +51,23 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
setState({
displayName: webhook.displayName,
url: webhook.url,
signingSecret: undefined,
});
}
});
}
}, [webhookName, currentUser]);
useEffect(() => {
if (open && isCreating) {
setState({
displayName: "",
url: "",
signingSecret: "",
});
}
}, [open, isCreating]);
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
@@ -73,6 +87,28 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
});
};
const handleSigningSecretInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
signingSecret: e.target.value,
});
};
const handleGenerateSecret = () => {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const secret = "whsec_" + btoa(String.fromCharCode(...bytes));
setPartialState({ signingSecret: secret });
setSecretCopied(false);
};
const handleCopySecret = () => {
if (!state.signingSecret) return;
copy(state.signingSecret.trim());
setSecretCopied(true);
setTimeout(() => setSecretCopied(false), 2000);
};
const normalizedSigningSecret = state.signingSecret?.trim() ?? "";
const handleSaveBtnClick = async () => {
if (!state.displayName || !state.url) {
toast.error(t("message.fill-all-required-fields"));
@@ -92,16 +128,22 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
webhook: {
displayName: state.displayName,
url: state.url,
signingSecret: normalizedSigningSecret,
},
});
} else {
const updateMaskPaths = ["display_name", "url"];
if (state.signingSecret !== undefined) {
updateMaskPaths.push("signing_secret");
}
await userServiceClient.updateUserWebhook({
webhook: {
name: webhookName,
displayName: state.displayName,
url: state.url,
...(state.signingSecret !== undefined && { signingSecret: normalizedSigningSecret }),
},
updateMask: create(FieldMaskSchema, { paths: ["display_name", "url"] }),
updateMask: create(FieldMaskSchema, { paths: updateMaskPaths }),
});
}
@@ -149,6 +191,33 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
onChange={handleUrlInputChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="signingSecret">{t("setting.webhook.create-dialog.signing-secret")}</Label>
<span className="text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-description")}</span>
<div className="flex gap-2">
<Input
id="signingSecret"
type="password"
placeholder={t("setting.webhook.create-dialog.signing-secret-placeholder")}
value={state.signingSecret ?? ""}
onChange={handleSigningSecretInputChange}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleCopySecret}
disabled={!state.signingSecret}
aria-label={t("setting.webhook.create-dialog.copy-secret")}
>
{secretCopied ? <CheckIcon className="h-4 w-4 text-green-500" /> : <CopyIcon className="h-4 w-4" />}
</Button>
<Button type="button" variant="outline" onClick={handleGenerateSecret}>
{t("setting.webhook.create-dialog.generate-secret")}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
+5
View File
@@ -739,8 +739,13 @@
"an-easy-to-remember-name": "An easy-to-remember name",
"create-webhook": "Create webhook",
"create-webhook-success": "Webhook `{{name}}` created",
"copy-secret": "Copy signing secret",
"edit-webhook": "Edit webhook",
"generate-secret": "Generate",
"payload-url": "Payload URL",
"signing-secret": "Signing Secret",
"signing-secret-description": "Optional. Used to HMAC-SHA256 sign the request body following the Standard Webhooks spec.",
"signing-secret-placeholder": "Enter a secret or generate one",
"title": "Title",
"url-example-post-receive": "https://example.com/postreceive"
},
+5
View File
@@ -642,8 +642,13 @@
"an-easy-to-remember-name": "请输入一个容易记住的标题",
"create-webhook": "创建 Webhook",
"create-webhook-success": "Webhook `{{name}}` 已创建",
"copy-secret": "复制签名密钥",
"edit-webhook": "编辑 Webhook",
"generate-secret": "生成",
"payload-url": "请输入有效的 URL",
"signing-secret": "签名密钥",
"signing-secret-description": "可选。用于对请求体进行 HMAC-SHA256 签名,遵循 Standard Webhooks 规范。",
"signing-secret-placeholder": "输入密钥或点击生成",
"title": "标题",
"url-example-post-receive": "https://example.com/postreceive"
},
File diff suppressed because one or more lines are too long