338 lines
11 KiB
Go
338 lines
11 KiB
Go
package callbacksimp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/tech/sendico/pkg/api/http/response"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
"github.com/tech/sendico/pkg/model"
|
|
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type callbackWriteResponse struct {
|
|
AccessToken sresponse.TokenData `json:"accessToken"`
|
|
Callbacks []model.Callback `json:"callbacks"`
|
|
GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"`
|
|
}
|
|
|
|
func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
|
organizationRef, err := a.Oph.GetRef(r)
|
|
if err != nil {
|
|
a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
|
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
|
}
|
|
|
|
var callback model.Callback
|
|
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
|
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
|
return response.BadPayload(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, true)
|
|
if err != nil {
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
if err := a.DB.Create(r.Context(), *account.GetID(), organizationRef, &callback); err != nil {
|
|
a.Logger.Warn("Failed to create callback", zap.Error(err))
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
return a.callbackResponse(&callback, accessToken, generatedSecret, true)
|
|
}
|
|
|
|
func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
|
var input model.Callback
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
|
return response.BadPayload(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
callbackRef := *input.GetID()
|
|
if callbackRef.IsZero() {
|
|
return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback id is required", "id"))
|
|
}
|
|
|
|
var existing model.Callback
|
|
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil {
|
|
a.Logger.Warn("Failed to fetch callback before update", zap.Error(err))
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
mergeCallbackMutable(&existing, &input)
|
|
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, true)
|
|
if err != nil {
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
if err := a.DB.Update(r.Context(), *account.GetID(), &existing); err != nil {
|
|
a.Logger.Warn("Failed to update callback", zap.Error(err))
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
return a.callbackResponse(&existing, accessToken, generatedSecret, false)
|
|
}
|
|
|
|
func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
|
callbackRef, err := a.Cph.GetRef(r)
|
|
if err != nil {
|
|
a.Logger.Warn("Failed to parse callback reference", zap.Error(err), mutil.PLog(a.Cph, r))
|
|
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
|
|
}
|
|
|
|
var callback model.Callback
|
|
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &callback); err != nil {
|
|
a.Logger.Warn("Failed to fetch callback for secret rotation", zap.Error(err))
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
if callback.RetryPolicy.SigningMode != model.CallbackSigningModeHMACSHA256 {
|
|
return response.BadRequest(a.Logger, a.Name(), "invalid_signing_mode", "rotate-secret is available only for hmac_sha256 callbacks")
|
|
}
|
|
|
|
secretRef, generatedSecret, err := a.secrets.Provision(r.Context(), callback.OrganizationRef, callbackRef)
|
|
if err != nil {
|
|
a.Logger.Warn("Failed to rotate callback signing secret", zap.Error(err))
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
callback.RetryPolicy.SecretRef = secretRef
|
|
|
|
if err := a.DB.Update(r.Context(), *account.GetID(), &callback); err != nil {
|
|
a.Logger.Warn("Failed to persist rotated callback secret reference", zap.Error(err))
|
|
return response.Auto(a.Logger, a.Name(), err)
|
|
}
|
|
|
|
return a.callbackResponse(&callback, accessToken, generatedSecret, false)
|
|
}
|
|
|
|
func (a *CallbacksAPI) normalizeAndPrepare(
|
|
ctx context.Context,
|
|
callback *model.Callback,
|
|
organizationRef bson.ObjectID,
|
|
allowSecretGeneration bool,
|
|
) (string, error) {
|
|
if callback == nil {
|
|
return "", merrors.InvalidArgument("callback payload is required")
|
|
}
|
|
if organizationRef.IsZero() {
|
|
return "", merrors.InvalidArgument("organization reference is required", "organizationRef")
|
|
}
|
|
|
|
callback.SetOrganizationRef(organizationRef)
|
|
callback.Name = strings.TrimSpace(callback.Name)
|
|
callback.Description = trimDescription(callback.Description)
|
|
callback.ClientID = strings.TrimSpace(callback.ClientID)
|
|
if callback.ClientID == "" {
|
|
callback.ClientID = organizationRef.Hex()
|
|
}
|
|
|
|
callback.URL = strings.TrimSpace(callback.URL)
|
|
if callback.URL == "" {
|
|
return "", merrors.InvalidArgument("url is required", "url")
|
|
}
|
|
if err := validateCallbackURL(callback.URL); err != nil {
|
|
return "", err
|
|
}
|
|
if callback.Name == "" {
|
|
callback.Name = callback.URL
|
|
}
|
|
|
|
status, err := normalizeStatus(callback.Status, a.config.DefaultStatus)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
callback.Status = status
|
|
callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes)
|
|
|
|
callback.RetryPolicy.MinDelayMS = defaultInt(callback.RetryPolicy.MinDelayMS, defaultRetryMinDelayMS)
|
|
callback.RetryPolicy.MaxDelayMS = defaultInt(callback.RetryPolicy.MaxDelayMS, defaultRetryMaxDelayMS)
|
|
if callback.RetryPolicy.MaxDelayMS < callback.RetryPolicy.MinDelayMS {
|
|
callback.RetryPolicy.MaxDelayMS = callback.RetryPolicy.MinDelayMS
|
|
}
|
|
callback.RetryPolicy.MaxAttempts = defaultInt(callback.RetryPolicy.MaxAttempts, defaultRetryMaxAttempts)
|
|
callback.RetryPolicy.RequestTimeoutMS = defaultInt(callback.RetryPolicy.RequestTimeoutMS, defaultRetryRequestTimeoutMS)
|
|
callback.RetryPolicy.Headers = normalizeHeaders(callback.RetryPolicy.Headers)
|
|
|
|
mode, err := normalizeSigningMode(callback.RetryPolicy.SigningMode)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
callback.RetryPolicy.SigningMode = mode
|
|
|
|
callback.RetryPolicy.SecretRef = strings.TrimSpace(callback.RetryPolicy.SecretRef)
|
|
switch callback.RetryPolicy.SigningMode {
|
|
case model.CallbackSigningModeNone:
|
|
callback.RetryPolicy.SecretRef = ""
|
|
return "", nil
|
|
case model.CallbackSigningModeHMACSHA256:
|
|
if callback.RetryPolicy.SecretRef != "" {
|
|
return "", nil
|
|
}
|
|
if !allowSecretGeneration {
|
|
return "", merrors.InvalidArgument("secretRef is required for hmac_sha256 callbacks", "retryPolicy.secretRef")
|
|
}
|
|
if callback.GetID().IsZero() {
|
|
callback.SetID(bson.NewObjectID())
|
|
}
|
|
secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
callback.RetryPolicy.SecretRef = secretRef
|
|
return generatedSecret, nil
|
|
default:
|
|
return "", merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
|
|
}
|
|
}
|
|
|
|
func (a *CallbacksAPI) callbackResponse(
|
|
callback *model.Callback,
|
|
accessToken *sresponse.TokenData,
|
|
generatedSecret string,
|
|
created bool,
|
|
) http.HandlerFunc {
|
|
if callback == nil || accessToken == nil {
|
|
return response.Internal(a.Logger, a.Name(), merrors.Internal("failed to build callback response"))
|
|
}
|
|
|
|
resp := callbackWriteResponse{
|
|
AccessToken: *accessToken,
|
|
Callbacks: []model.Callback{*callback},
|
|
GeneratedSigningSecret: generatedSecret,
|
|
}
|
|
if created {
|
|
return response.Created(a.Logger, resp)
|
|
}
|
|
return response.Ok(a.Logger, resp)
|
|
}
|
|
|
|
func normalizeStatus(raw, fallback model.CallbackStatus) (model.CallbackStatus, error) {
|
|
candidate := strings.ToLower(strings.TrimSpace(string(raw)))
|
|
if candidate == "" {
|
|
candidate = strings.ToLower(strings.TrimSpace(string(fallback)))
|
|
}
|
|
|
|
switch candidate {
|
|
case "", "active", "enabled":
|
|
return model.CallbackStatusActive, nil
|
|
case "disabled", "inactive":
|
|
return model.CallbackStatusDisabled, nil
|
|
default:
|
|
return "", merrors.InvalidArgument("unsupported callback status", "status")
|
|
}
|
|
}
|
|
|
|
func normalizeSigningMode(raw model.CallbackSigningMode) (model.CallbackSigningMode, error) {
|
|
mode := strings.ToLower(strings.TrimSpace(string(raw)))
|
|
switch mode {
|
|
case "", "none":
|
|
return model.CallbackSigningModeNone, nil
|
|
case "hmac_sha256", "hmac-sha256", "hmac":
|
|
return model.CallbackSigningModeHMACSHA256, nil
|
|
default:
|
|
return "", merrors.InvalidArgument("unsupported callback signing mode", "retryPolicy.signingMode")
|
|
}
|
|
}
|
|
|
|
func normalizeEventTypes(eventTypes []string, defaults []string) []string {
|
|
if len(eventTypes) == 0 {
|
|
return normalizeEventTypes(defaults, nil)
|
|
}
|
|
seen := make(map[string]struct{}, len(eventTypes))
|
|
out := make([]string, 0, len(eventTypes))
|
|
for _, eventType := range eventTypes {
|
|
value := strings.TrimSpace(eventType)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
if len(out) == 0 {
|
|
if len(defaults) > 0 {
|
|
return normalizeEventTypes(defaults, nil)
|
|
}
|
|
return []string{model.PaymentStatusUpdatedType}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeHeaders(headers map[string]string) map[string]string {
|
|
if len(headers) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(headers))
|
|
for key, value := range headers {
|
|
k := strings.TrimSpace(key)
|
|
if k == "" {
|
|
continue
|
|
}
|
|
out[k] = strings.TrimSpace(value)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeCallbackMutable(dst, src *model.Callback) {
|
|
dst.Describable = src.Describable
|
|
dst.ClientID = src.ClientID
|
|
dst.Status = src.Status
|
|
dst.URL = src.URL
|
|
dst.EventTypes = append([]string(nil), src.EventTypes...)
|
|
dst.RetryPolicy = model.CallbackRetryPolicy{
|
|
MinDelayMS: src.RetryPolicy.MinDelayMS,
|
|
MaxDelayMS: src.RetryPolicy.MaxDelayMS,
|
|
SigningMode: src.RetryPolicy.SigningMode,
|
|
SecretRef: src.RetryPolicy.SecretRef,
|
|
Headers: normalizeHeaders(src.RetryPolicy.Headers),
|
|
MaxAttempts: src.RetryPolicy.MaxAttempts,
|
|
RequestTimeoutMS: src.RetryPolicy.RequestTimeoutMS,
|
|
}
|
|
}
|
|
|
|
func defaultInt(value, fallback int) int {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func trimDescription(in *string) *string {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
value := strings.TrimSpace(*in)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|
|
|
|
func validateCallbackURL(raw string) error {
|
|
parsed, err := url.ParseRequestURI(raw)
|
|
if err != nil {
|
|
return merrors.InvalidArgument("url is invalid", "url")
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
|
case "https", "http":
|
|
default:
|
|
return merrors.InvalidArgument("url scheme must be http or https", "url")
|
|
}
|
|
if strings.TrimSpace(parsed.Host) == "" {
|
|
return merrors.InvalidArgument("url host is required", "url")
|
|
}
|
|
return nil
|
|
}
|