269 lines
7.3 KiB
Go
269 lines
7.3 KiB
Go
/*
|
|
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 apns
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"sync"
|
|
|
|
jsoniter "github.com/json-iterator/go"
|
|
|
|
"github.com/dapr/components-contrib/bindings"
|
|
contribMetadata "github.com/dapr/components-contrib/metadata"
|
|
"github.com/dapr/kit/logger"
|
|
)
|
|
|
|
const (
|
|
collapseIDKey = "apns-collapse-id"
|
|
developmentKey = "development"
|
|
developmentPrefix = "https://api.sandbox.push.apple.com/3/device/"
|
|
deviceTokenKey = "device-token"
|
|
expirationKey = "apns-expiration"
|
|
keyIDKey = "key-id"
|
|
messageIDKey = "apns-id"
|
|
priorityKey = "apns-priority"
|
|
privateKeyKey = "private-key"
|
|
productionPrefix = "https://api.push.apple.com/3/device/"
|
|
pushTypeKey = "apns-push-type"
|
|
teamIDKey = "team-id"
|
|
topicKey = "apns-topic"
|
|
)
|
|
|
|
type notificationResponse struct {
|
|
MessageID string `json:"messageID"`
|
|
}
|
|
|
|
type errorResponse struct {
|
|
Reason string `json:"reason"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
// APNS implements an outbound binding that allows services to send push
|
|
// notifications to Apple devices using Apple's Push Notification Service.
|
|
type APNS struct {
|
|
logger logger.Logger
|
|
client *http.Client
|
|
urlPrefix string
|
|
authorizationBuilder *authorizationBuilder
|
|
}
|
|
|
|
type APNSmetadata struct {
|
|
Development bool `mapstructure:"development"`
|
|
KeyID string `mapstructure:"key-id"`
|
|
TeamID string `mapstructure:"team-id"`
|
|
PrivateKey string `mapstructure:"private-key"`
|
|
}
|
|
|
|
// NewAPNS will create a new APNS output binding.
|
|
func NewAPNS(logger logger.Logger) bindings.OutputBinding {
|
|
return &APNS{
|
|
logger: logger,
|
|
client: &http.Client{},
|
|
authorizationBuilder: &authorizationBuilder{
|
|
logger: logger,
|
|
mutex: sync.RWMutex{},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Init will configure the APNS output binding using the metadata specified
|
|
// in the binding's configuration.
|
|
func (a *APNS) Init(ctx context.Context, metadata bindings.Metadata) error {
|
|
m := APNSmetadata{}
|
|
err := contribMetadata.DecodeMetadata(metadata.Properties, &m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.makeURLPrefix(m); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.extractKeyID(m); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.extractTeamID(m); err != nil {
|
|
return err
|
|
}
|
|
|
|
return a.extractPrivateKey(m)
|
|
}
|
|
|
|
// Operations will return the set of operations supported by the APNS output
|
|
// binding. The APNS output binding only supports the "create" operation for
|
|
// sending new push notifications to the APNS service.
|
|
func (a *APNS) Operations() []bindings.OperationKind {
|
|
return []bindings.OperationKind{bindings.CreateOperation}
|
|
}
|
|
|
|
// Invoke is called by Dapr to send a push notification to the APNS output
|
|
// binding.
|
|
func (a *APNS) Invoke(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
|
|
if req.Operation != bindings.CreateOperation {
|
|
return nil, fmt.Errorf("operation not supported: %v", req.Operation)
|
|
}
|
|
|
|
return a.sendPushNotification(ctx, req)
|
|
}
|
|
|
|
func (a *APNS) sendPushNotification(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
|
|
deviceToken, ok := req.Metadata[deviceTokenKey]
|
|
if !ok || deviceToken == "" {
|
|
return nil, errors.New("the device-token parameter is required")
|
|
}
|
|
|
|
httpResponse, err := a.sendPushNotificationToAPNS(ctx, deviceToken, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() {
|
|
// Drain before closing
|
|
_, _ = io.Copy(io.Discard, httpResponse.Body)
|
|
_ = httpResponse.Body.Close()
|
|
}()
|
|
|
|
if httpResponse.StatusCode == http.StatusOK {
|
|
return makeSuccessResponse(httpResponse)
|
|
}
|
|
|
|
return makeErrorResponse(httpResponse)
|
|
}
|
|
|
|
func (a *APNS) sendPushNotificationToAPNS(ctx context.Context, deviceToken string, req *bindings.InvokeRequest) (*http.Response, error) {
|
|
url := a.urlPrefix + deviceToken
|
|
httpRequest, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
url,
|
|
bytes.NewReader(req.Data),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
authorizationHeader, err := a.authorizationBuilder.getAuthorizationHeader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpRequest.Header.Add("authorization", authorizationHeader)
|
|
addRequestHeader(pushTypeKey, req.Metadata, httpRequest)
|
|
addRequestHeader(messageIDKey, req.Metadata, httpRequest)
|
|
addRequestHeader(expirationKey, req.Metadata, httpRequest)
|
|
addRequestHeader(priorityKey, req.Metadata, httpRequest)
|
|
addRequestHeader(topicKey, req.Metadata, httpRequest)
|
|
addRequestHeader(collapseIDKey, req.Metadata, httpRequest)
|
|
|
|
return a.client.Do(httpRequest)
|
|
}
|
|
|
|
func (a *APNS) makeURLPrefix(metadata APNSmetadata) error {
|
|
if metadata.Development {
|
|
a.logger.Debug("Using the development APNS service")
|
|
a.urlPrefix = developmentPrefix
|
|
} else {
|
|
a.logger.Debug("Using the production APNS service")
|
|
a.urlPrefix = productionPrefix
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *APNS) extractKeyID(metadata APNSmetadata) error {
|
|
if metadata.KeyID != "" {
|
|
a.authorizationBuilder.keyID = metadata.KeyID
|
|
|
|
return nil
|
|
}
|
|
|
|
return errors.New("the key-id parameter is required")
|
|
}
|
|
|
|
func (a *APNS) extractTeamID(metadata APNSmetadata) error {
|
|
if metadata.TeamID != "" {
|
|
a.authorizationBuilder.teamID = metadata.TeamID
|
|
|
|
return nil
|
|
}
|
|
|
|
return errors.New("the team-id parameter is required")
|
|
}
|
|
|
|
func (a *APNS) extractPrivateKey(metadata APNSmetadata) error {
|
|
if metadata.PrivateKey != "" {
|
|
block, _ := pem.Decode([]byte(metadata.PrivateKey))
|
|
if block == nil {
|
|
return errors.New("unable to read the private key")
|
|
}
|
|
|
|
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a.authorizationBuilder.privateKey = privateKey
|
|
} else {
|
|
return errors.New("the private-key parameter is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func addRequestHeader(key string, metadata map[string]string, httpRequest *http.Request) {
|
|
if value, ok := metadata[key]; ok && value != "" {
|
|
httpRequest.Header.Add(key, value)
|
|
}
|
|
}
|
|
|
|
func makeSuccessResponse(httpResponse *http.Response) (*bindings.InvokeResponse, error) {
|
|
messageID := httpResponse.Header.Get(messageIDKey)
|
|
output := notificationResponse{MessageID: messageID}
|
|
var data bytes.Buffer
|
|
encoder := jsoniter.NewEncoder(&data)
|
|
err := encoder.Encode(output)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &bindings.InvokeResponse{Data: data.Bytes()}, nil
|
|
}
|
|
|
|
func makeErrorResponse(httpResponse *http.Response) (*bindings.InvokeResponse, error) {
|
|
var errorReply errorResponse
|
|
decoder := jsoniter.NewDecoder(httpResponse.Body)
|
|
err := decoder.Decode(&errorReply)
|
|
if err == nil {
|
|
err = errors.New(errorReply.Reason)
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
// GetComponentMetadata returns the metadata of the component.
|
|
func (a *APNS) GetComponentMetadata() (metadataInfo contribMetadata.MetadataMap) {
|
|
metadataStruct := APNSmetadata{}
|
|
contribMetadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo, contribMetadata.BindingType)
|
|
return
|
|
}
|