feat: add optional webhook signing secret (Standard Webhooks HMAC-SHA256) (#6013)
This commit is contained in:
@@ -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 (0x20–0x7E), 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user