/* Copyright 2021 The Dapr Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package webhook import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "reflect" "strconv" "sync" "time" "github.com/dapr/components-contrib/bindings" contribMetadata "github.com/dapr/components-contrib/metadata" "github.com/dapr/kit/logger" ) const ( webhookContentType = "application/json" defaultHTTPClientTimeout = time.Second * 30 ) type DingTalkWebhook struct { logger logger.Logger settings Settings httpClient *http.Client } type webhookResult struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } type outgoingWebhook struct { handler bindings.Handler } var webhooks = struct { //nolint:gochecknoglobals sync.RWMutex m map[string]*outgoingWebhook }{m: make(map[string]*outgoingWebhook)} func NewDingTalkWebhook(l logger.Logger) bindings.InputOutputBinding { // See guidance on proper HTTP client settings here: // https://medium.com/@nate510/don-t-use-go-s-default-http-client-4804cb19f779 dialer := &net.Dialer{ //nolint:exhaustivestruct Timeout: 5 * time.Second, } netTransport := &http.Transport{ //nolint:exhaustivestruct DialContext: dialer.DialContext, TLSHandshakeTimeout: 5 * time.Second, } httpClient := &http.Client{ //nolint:exhaustivestruct Timeout: defaultHTTPClientTimeout, Transport: netTransport, } return &DingTalkWebhook{ //nolint:exhaustivestruct logger: l, httpClient: httpClient, } } // Init performs metadata parsing. func (t *DingTalkWebhook) Init(_ context.Context, metadata bindings.Metadata) error { var err error if err = t.settings.Decode(metadata.Properties); err != nil { return fmt.Errorf("dingtalk configuration error: %w", err) } if err = t.settings.Validate(); err != nil { return fmt.Errorf("dingtalk configuration error: %w", err) } return nil } // Read triggers the outgoing webhook, not yet production ready. func (t *DingTalkWebhook) Read(ctx context.Context, handler bindings.Handler) error { t.logger.Debugf("dingtalk webhook: start read input binding") webhooks.Lock() defer webhooks.Unlock() _, loaded := webhooks.m[t.settings.ID] if loaded { return fmt.Errorf("dingtalk webhook error: duplicate id %s", t.settings.ID) } webhooks.m[t.settings.ID] = &outgoingWebhook{handler: handler} return nil } func (t *DingTalkWebhook) Close() error { webhooks.Lock() defer webhooks.Unlock() delete(webhooks.m, t.settings.ID) return nil } // Operations returns list of operations supported by dingtalk webhook binding. func (t *DingTalkWebhook) Operations() []bindings.OperationKind { return []bindings.OperationKind{bindings.CreateOperation, bindings.GetOperation} } func (t *DingTalkWebhook) Invoke(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) { rst := &bindings.InvokeResponse{Metadata: map[string]string{}, Data: nil} switch req.Operation { case bindings.CreateOperation: return rst, t.sendMessage(ctx, req) case bindings.GetOperation: return rst, t.receivedMessage(ctx, req) case bindings.DeleteOperation, bindings.ListOperation: return rst, fmt.Errorf("dingtalk webhook error: unsupported operation %s", req.Operation) default: return rst, fmt.Errorf("dingtalk webhook error: unsupported operation %s", req.Operation) } } func (t *DingTalkWebhook) getOutgoingWebhook() (*outgoingWebhook, error) { webhooks.RLock() defer webhooks.RUnlock() item, loaded := webhooks.m[t.settings.ID] if !loaded { return nil, fmt.Errorf("dingtalk webhook error: invalid component metadata.id %s", t.settings.ID) } return item, nil } func (t *DingTalkWebhook) receivedMessage(ctx context.Context, req *bindings.InvokeRequest) error { item, err := t.getOutgoingWebhook() if err != nil { return err } in := &bindings.ReadResponse{Data: req.Data, Metadata: req.Metadata} if _, err = item.handler(ctx, in); err != nil { return err } return nil } func (t *DingTalkWebhook) sendMessage(ctx context.Context, req *bindings.InvokeRequest) error { msg := req.Data postURL, err := getPostURL(t.settings.URL, t.settings.Secret) if err != nil { return fmt.Errorf("dingtalk webhook error: get url failed. %w", err) } ctx, cancel := context.WithTimeout(ctx, defaultHTTPClientTimeout) defer cancel() httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, bytes.NewReader(msg)) if err != nil { return fmt.Errorf("dingtalk webhook error: new request failed. %w", err) } httpReq.Header.Add("Accept", webhookContentType) httpReq.Header.Add("Content-Type", webhookContentType) resp, err := t.httpClient.Do(httpReq) if err != nil { return fmt.Errorf("dingtalk webhook error: post failed. %w", err) } defer func() { // Drain before closing _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("dingtalk webhook error: post failed. status:%d", resp.StatusCode) } var rst webhookResult err = json.NewDecoder(resp.Body).Decode(&rst) if err != nil { return fmt.Errorf("dingtalk webhook error: unmarshal body failed. %w", err) } if rst.ErrCode != 0 { return fmt.Errorf("dingtalk webhook error: send msg failed. %v", rst.ErrMsg) } return nil } // GetComponentMetadata returns the metadata of the component. func (t *DingTalkWebhook) GetComponentMetadata() (metadataInfo contribMetadata.MetadataMap) { metadataStruct := Settings{} contribMetadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo, contribMetadata.BindingType) return } func getPostURL(urlPath, secret string) (string, error) { if secret == "" { return urlPath, nil } timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) sign, err := sign(secret, timestamp) if err != nil { return urlPath, err } query := url.Values{} query.Set("timestamp", timestamp) query.Set("sign", sign) return urlPath + "&" + query.Encode(), nil } func sign(secret, timestamp string) (string, error) { stringToSign := fmt.Sprintf("%s\n%s", timestamp, secret) h := hmac.New(sha256.New, []byte(secret)) _, _ = h.Write([]byte(stringToSign)) dgst := h.Sum(nil) return base64.StdEncoding.EncodeToString(dgst), nil }