Merge branch 'main' into rebuild/auth-identity-foundation
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -11,9 +12,22 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// validateProviderConfig runs the provider's constructor to surface config-level
|
||||
// errors at save time (e.g. wxpay missing certSerial), instead of only failing
|
||||
// when an order is created. Returns the structured ApplicationError from the
|
||||
// constructor so the frontend i18n layer can localize it.
|
||||
//
|
||||
// Only validates enabled instances — a disabled instance may be a half-filled
|
||||
// draft the admin will complete later.
|
||||
func (s *PaymentConfigService) validateProviderConfig(providerKey string, config map[string]string) error {
|
||||
_, err := provider.CreateProvider(providerKey, "_validate_", config)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Provider Instance CRUD ---
|
||||
|
||||
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
|
||||
@@ -47,11 +61,10 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte
|
||||
resp := ProviderInstanceResponse{
|
||||
ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
|
||||
SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
|
||||
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled,
|
||||
AllowUserRefund: inst.AllowUserRefund,
|
||||
SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
|
||||
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, AllowUserRefund: inst.AllowUserRefund,
|
||||
SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
|
||||
}
|
||||
resp.Config, err = s.decryptAndMaskConfig(inst.Config)
|
||||
resp.Config, err = s.decryptAndMaskConfig(inst.ProviderKey, inst.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err)
|
||||
}
|
||||
@@ -60,8 +73,26 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) decryptAndMaskConfig(encrypted string) (map[string]string, error) {
|
||||
return s.decryptConfig(encrypted)
|
||||
// decryptAndMaskConfig returns the stored config with sensitive fields omitted.
|
||||
// Admin UIs display masked placeholders for these; the raw values never leave
|
||||
// the server. Callers that need the full config (e.g. payment runtime) must
|
||||
// use decryptConfig directly.
|
||||
func (s *PaymentConfigService) decryptAndMaskConfig(providerKey, encrypted string) (map[string]string, error) {
|
||||
cfg, err := s.decryptConfig(encrypted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
masked := make(map[string]string, len(cfg))
|
||||
for k, v := range cfg {
|
||||
if isSensitiveProviderConfigField(providerKey, k) {
|
||||
continue
|
||||
}
|
||||
masked[k] = v
|
||||
}
|
||||
return masked, nil
|
||||
}
|
||||
|
||||
// pendingOrderStatuses are order statuses considered "in progress".
|
||||
@@ -71,16 +102,27 @@ var pendingOrderStatuses = []string{
|
||||
payment.OrderStatusRecharging,
|
||||
}
|
||||
|
||||
var sensitiveConfigPatterns = []string{"key", "pkey", "secret", "private", "password"}
|
||||
// providerSensitiveConfigFields is the authoritative list of config keys that
|
||||
// are treated as secrets per provider. Must stay in sync with the frontend
|
||||
// definition at frontend/src/components/payment/providerConfig.ts
|
||||
// (PROVIDER_CONFIG_FIELDS, fields with sensitive: true).
|
||||
//
|
||||
// Key matching is case-insensitive. Non-listed keys (e.g. appId, notifyUrl,
|
||||
// stripe publishableKey) are returned in plaintext by the admin GET API.
|
||||
var providerSensitiveConfigFields = map[string]map[string]struct{}{
|
||||
payment.TypeEasyPay: {"pkey": {}},
|
||||
payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}},
|
||||
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}},
|
||||
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
|
||||
}
|
||||
|
||||
func isSensitiveConfigField(fieldName string) bool {
|
||||
lower := strings.ToLower(fieldName)
|
||||
for _, p := range sensitiveConfigPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
|
||||
fields, ok := providerSensitiveConfigFields[providerKey]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
_, found := fields[strings.ToLower(fieldName)]
|
||||
return found
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
|
||||
@@ -111,6 +153,11 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
|
||||
if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Enabled {
|
||||
if err := s.validateProviderConfig(req.ProviderKey, req.Config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
enc, err := s.encryptConfig(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -141,7 +188,7 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error {
|
||||
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||
current, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("load provider instance: %w", err)
|
||||
}
|
||||
nextEnabled := current.Enabled
|
||||
if req.Enabled != nil {
|
||||
@@ -156,8 +203,8 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
||||
}
|
||||
if req.Config != nil {
|
||||
hasSensitive := false
|
||||
for k := range req.Config {
|
||||
if isSensitiveConfigField(k) && req.Config[k] != "" {
|
||||
for k, v := range req.Config {
|
||||
if v != "" && isSensitiveProviderConfigField(current.ProviderKey, k) {
|
||||
hasSensitive = true
|
||||
break
|
||||
}
|
||||
@@ -183,16 +230,38 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
||||
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
||||
}
|
||||
}
|
||||
// Validate merged config when the instance will end up enabled.
|
||||
// This surfaces provider-level errors (e.g. wxpay missing certSerial) at save time,
|
||||
// so admins see them in the dialog instead of only when an order is created.
|
||||
finalEnabled := current.Enabled
|
||||
if req.Enabled != nil {
|
||||
finalEnabled = *req.Enabled
|
||||
}
|
||||
var mergedConfig map[string]string
|
||||
if req.Config != nil {
|
||||
mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if finalEnabled {
|
||||
configToValidate := mergedConfig
|
||||
if configToValidate == nil {
|
||||
configToValidate, err = s.decryptConfig(current.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt existing config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.validateProviderConfig(current.ProviderKey, configToValidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
|
||||
if req.Name != nil {
|
||||
u.SetName(*req.Name)
|
||||
}
|
||||
if req.Config != nil {
|
||||
merged, err := s.mergeConfig(ctx, id, req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc, err := s.encryptConfig(merged)
|
||||
if mergedConfig != nil {
|
||||
enc, err := s.encryptConfig(mergedConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -293,27 +362,48 @@ func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newCon
|
||||
return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err)
|
||||
}
|
||||
if existing == nil {
|
||||
return newConfig, nil
|
||||
existing = map[string]string{}
|
||||
}
|
||||
for k, v := range newConfig {
|
||||
// Preserve existing secrets when the client submits an empty value
|
||||
// (admin UI omits the value to indicate "leave unchanged").
|
||||
if v == "" && isSensitiveProviderConfigField(inst.ProviderKey, k) {
|
||||
continue
|
||||
}
|
||||
existing[k] = v
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) decryptConfig(encrypted string) (map[string]string, error) {
|
||||
if encrypted == "" {
|
||||
// decryptConfig parses a stored provider config.
|
||||
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext
|
||||
// ("iv:authTag:ciphertext"). Values that cannot be parsed as either — including
|
||||
// legacy ciphertext with no/invalid TOTP_ENCRYPTION_KEY — are treated as empty,
|
||||
// letting the admin re-enter the config via the UI to complete the migration.
|
||||
//
|
||||
// TODO(deprecated-legacy-ciphertext): The AES fallback branch is a transitional
|
||||
// shim for pre-plaintext records. Remove it (and the encryptionKey field) after
|
||||
// a few releases once all live deployments have re-saved their provider configs.
|
||||
func (s *PaymentConfigService) decryptConfig(stored string) (map[string]string, error) {
|
||||
if stored == "" {
|
||||
return nil, nil
|
||||
}
|
||||
decrypted, err := payment.Decrypt(encrypted, s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt config: %w", err)
|
||||
var cfg map[string]string
|
||||
if err := json.Unmarshal([]byte(stored), &cfg); err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
var raw map[string]string
|
||||
if err := json.Unmarshal([]byte(decrypted), &raw); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal decrypted config: %w", err)
|
||||
// Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
|
||||
if len(s.encryptionKey) == payment.AES256KeySize {
|
||||
//nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
|
||||
if plaintext, err := payment.Decrypt(stored, s.encryptionKey); err == nil {
|
||||
if err := json.Unmarshal([]byte(plaintext), &cfg); err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return raw, nil
|
||||
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
|
||||
"stored_len", len(stored))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
|
||||
@@ -328,14 +418,13 @@ func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id in
|
||||
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
// encryptConfig serialises a provider config for storage.
|
||||
// New records are written as plaintext JSON; the historical AES-GCM wrapping
|
||||
// has been dropped but decryptConfig still accepts old ciphertext during migration.
|
||||
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
enc, err := payment.Encrypt(string(data), s.encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt config: %w", err)
|
||||
}
|
||||
return enc, nil
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user