package callbacksimp import ( "context" "errors" "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 signingSecretMutation struct { SetSecretRef string Clear bool Generated string } 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) } if err := a.db.SetSigningSecretRef(r.Context(), *account.GetID(), callbackRef, secretRef); err != nil { a.Logger.Warn("Failed to persist rotated callback signing 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, existingSecretRef string, allowSecretGeneration bool, ) (signingSecretMutation, error) { if callback == nil { return signingSecretMutation{}, merrors.InvalidArgument("callback payload is required") } if organizationRef.IsZero() { return signingSecretMutation{}, merrors.InvalidArgument("organization reference is required", "organizationRef") } callback.Name = strings.TrimSpace(callback.Name) callback.Description = trimDescription(callback.Description) callback.URL = strings.TrimSpace(callback.URL) if callback.URL == "" { return signingSecretMutation{}, merrors.InvalidArgument("url is required", "url") } if err := validateCallbackURL(callback.URL); err != nil { return signingSecretMutation{}, err } if callback.Name == "" { callback.Name = callback.URL } status, err := normalizeStatus(callback.Status, a.config.DefaultStatus) if err != nil { return signingSecretMutation{}, err } callback.Status = status callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes) callback.RetryPolicy.Backoff.MinDelayMS = defaultInt(callback.RetryPolicy.Backoff.MinDelayMS, defaultRetryMinDelayMS) callback.RetryPolicy.Backoff.MaxDelayMS = defaultInt(callback.RetryPolicy.Backoff.MaxDelayMS, defaultRetryMaxDelayMS) if callback.RetryPolicy.Backoff.MaxDelayMS < callback.RetryPolicy.Backoff.MinDelayMS { callback.RetryPolicy.Backoff.MaxDelayMS = callback.RetryPolicy.Backoff.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 signingSecretMutation{}, err } callback.RetryPolicy.SigningMode = mode existingSecretRef = strings.TrimSpace(existingSecretRef) switch callback.RetryPolicy.SigningMode { case model.CallbackSigningModeNone: return signingSecretMutation{Clear: existingSecretRef != ""}, nil case model.CallbackSigningModeHMACSHA256: if existingSecretRef != "" { return signingSecretMutation{SetSecretRef: existingSecretRef}, nil } if !allowSecretGeneration { return signingSecretMutation{}, merrors.InvalidArgument("signing secret is required for hmac_sha256 callbacks", "retryPolicy.signingMode") } if callback.GetID().IsZero() { callback.SetID(bson.NewObjectID()) } secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID()) if err != nil { return signingSecretMutation{}, err } return signingSecretMutation{SetSecretRef: secretRef, Generated: generatedSecret}, nil default: return signingSecretMutation{}, merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode") } } func (a *CallbacksAPI) applySigningSecretMutation( ctx context.Context, accountRef, callbackRef bson.ObjectID, mutation signingSecretMutation, ) error { if callbackRef.IsZero() { return merrors.InvalidArgument("callback reference is required", "callbackRef") } if strings.TrimSpace(mutation.SetSecretRef) != "" { return a.db.SetSigningSecretRef(ctx, accountRef, callbackRef, mutation.SetSecretRef) } if mutation.Clear { err := a.db.ClearSigningSecretRef(ctx, accountRef, callbackRef) if err != nil && !errors.Is(err, merrors.ErrNoData) { return err } } return nil } 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")) } return sresponse.Callback(a.Logger, callback, accessToken, generatedSecret, created) } 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.OrganizationRef = src.OrganizationRef dst.Describable = src.Describable dst.Status = src.Status dst.URL = src.URL dst.EventTypes = append([]string(nil), src.EventTypes...) dst.RetryPolicy = model.CallbackRetryPolicy{ Backoff: model.CallbackBackoff{ MinDelayMS: src.RetryPolicy.Backoff.MinDelayMS, MaxDelayMS: src.RetryPolicy.Backoff.MaxDelayMS, }, SigningMode: src.RetryPolicy.SigningMode, 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 }