From 8083411f3f0445883d95c3d01167fbe8cac02f41 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 17 Dec 2025 21:03:49 +0800 Subject: [PATCH] chore: update license validation --- .../web/src/pages/SubscriptionSetting.tsx | 4 +- server/service/license/gumroad/requests.go | 204 ++++++++++++++++++ server/service/license/license.go | 28 ++- 3 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 server/service/license/gumroad/requests.go diff --git a/frontend/web/src/pages/SubscriptionSetting.tsx b/frontend/web/src/pages/SubscriptionSetting.tsx index 11236ec..35ce6e5 100644 --- a/frontend/web/src/pages/SubscriptionSetting.tsx +++ b/frontend/web/src/pages/SubscriptionSetting.tsx @@ -76,7 +76,7 @@ const SubscriptionSetting: React.FC = () => {
{subscription.plan === PlanType.FREE && ( - + Buy a license key @@ -179,7 +179,7 @@ const SubscriptionSetting: React.FC = () => { diff --git a/server/service/license/gumroad/requests.go b/server/service/license/gumroad/requests.go new file mode 100644 index 0000000..cc8b7b3 --- /dev/null +++ b/server/service/license/gumroad/requests.go @@ -0,0 +1,204 @@ +package gumroad + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/pkg/errors" +) + +const ( + // The base API URL for the Gumroad API. + baseAPIURL = "https://api.gumroad.com" + // The product ID from Gumroad (base64 encoded). + productID = "mjSmJtQJcYNtu4vdxeQWNg==" +) + +type Purchase struct { + SellerID string `json:"seller_id"` + ProductID string `json:"product_id"` + ProductName string `json:"product_name"` + Permalink string `json:"permalink"` + ProductPermalink string `json:"product_permalink"` + Email string `json:"email"` + Price int `json:"price"` + GumroadFee int `json:"gumroad_fee"` + Currency string `json:"currency"` + Quantity int `json:"quantity"` + SaleID string `json:"sale_id"` + SaleTimestamp string `json:"sale_timestamp"` + PurchaserID string `json:"purchaser_id,omitempty"` + SubscriptionID string `json:"subscription_id,omitempty"` + LicenseKey string `json:"license_key"` + IsMultiseatLicense bool `json:"is_multiseat_license"` + IPCountry string `json:"ip_country"` + Recurrence string `json:"recurrence,omitempty"` + Refunded bool `json:"refunded"` + Disputed bool `json:"disputed"` + DisputeWon bool `json:"dispute_won"` + Chargebacked bool `json:"chargebacked"` + ID string `json:"id"` + CreatedAt string `json:"created_at"` + SubscriptionEndedAt *string `json:"subscription_ended_at"` + SubscriptionCancelledAt *string `json:"subscription_cancelled_at"` + SubscriptionFailedAt *string `json:"subscription_failed_at"` +} + +type ValidateLicenseKeyResponse struct { + Success bool `json:"success"` + Uses int `json:"uses"` + Purchase *Purchase `json:"purchase"` + Message *string `json:"message"` +} + +type ActiveLicenseKeyResponse struct { + Success bool `json:"success"` + Uses int `json:"uses"` + Purchase *Purchase `json:"purchase"` + Message *string `json:"message"` +} + +func ValidateLicenseKey(licenseKey string, instanceName string) (*ValidateLicenseKeyResponse, error) { + data := map[string]string{ + "product_id": productID, + "license_key": licenseKey, + } + if instanceName != "" { + data["increment_uses_count"] = "false" + } + payload, err := json.Marshal(data) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal data") + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/licenses/verify", baseAPIURL), bytes.NewBuffer(payload)) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to do request") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var response ValidateLicenseKeyResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + + // Check for errors + if response.Message != nil && *response.Message != "" { + return nil, errors.New(*response.Message) + } + + // Validate the response + if response.Success && response.Purchase != nil { + purchase := response.Purchase + + // Check if purchase was refunded/chargebacked + if purchase.Refunded { + return nil, errors.New("license key has been refunded") + } + if purchase.Chargebacked { + return nil, errors.New("license key has been chargebacked") + } + if purchase.Disputed && !purchase.DisputeWon { + return nil, errors.New("license key is disputed") + } + + // Check subscription status (for membership products) + if purchase.SubscriptionEndedAt != nil && *purchase.SubscriptionEndedAt != "" { + return nil, errors.New("subscription has ended") + } + if purchase.SubscriptionCancelledAt != nil && *purchase.SubscriptionCancelledAt != "" { + return nil, errors.New("subscription has been cancelled") + } + if purchase.SubscriptionFailedAt != nil && *purchase.SubscriptionFailedAt != "" { + return nil, errors.New("subscription payment failed") + } + } + + return &response, nil +} + +func ActiveLicenseKey(licenseKey string, instanceName string) (*ActiveLicenseKeyResponse, error) { + data := map[string]string{ + "product_id": productID, + "license_key": licenseKey, + "increment_uses_count": "true", + } + payload, err := json.Marshal(data) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal data") + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/licenses/verify", baseAPIURL), bytes.NewBuffer(payload)) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to do request") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var response ActiveLicenseKeyResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + + // Check for errors + if response.Message != nil && *response.Message != "" { + return nil, errors.New(*response.Message) + } + + // Validate the response + if response.Success && response.Purchase != nil { + purchase := response.Purchase + + // Check if purchase was refunded/chargebacked + if purchase.Refunded { + return nil, errors.New("license key has been refunded") + } + if purchase.Chargebacked { + return nil, errors.New("license key has been chargebacked") + } + if purchase.Disputed && !purchase.DisputeWon { + return nil, errors.New("license key is disputed") + } + + // Check subscription status (for membership products) + if purchase.SubscriptionEndedAt != nil && *purchase.SubscriptionEndedAt != "" { + return nil, errors.New("subscription has ended") + } + if purchase.SubscriptionCancelledAt != nil && *purchase.SubscriptionCancelledAt != "" { + return nil, errors.New("subscription has been cancelled") + } + if purchase.SubscriptionFailedAt != nil && *purchase.SubscriptionFailedAt != "" { + return nil, errors.New("subscription payment failed") + } + } + + return &response, nil +} diff --git a/server/service/license/license.go b/server/service/license/license.go index f3e1af9..edd3bdf 100644 --- a/server/service/license/license.go +++ b/server/service/license/license.go @@ -13,6 +13,7 @@ import ( v1pb "github.com/yourselfhosted/slash/proto/gen/api/v1" storepb "github.com/yourselfhosted/slash/proto/gen/store" "github.com/yourselfhosted/slash/server/profile" + "github.com/yourselfhosted/slash/server/service/license/gumroad" "github.com/yourselfhosted/slash/server/service/license/lemonsqueezy" "github.com/yourselfhosted/slash/store" ) @@ -159,18 +160,31 @@ func validateLicenseKey(licenseKey string) (*ValidateResult, error) { return result, nil } - // Try to validate the license key with the license server. - validateResponse, err := lemonsqueezy.ValidateLicenseKey(licenseKey, "") - if err != nil { - return nil, errors.Wrap(err, "failed to validate license key") + // Try to validate the license key with Gumroad. + gumroadResponse, err := gumroad.ValidateLicenseKey(licenseKey, "") + if err == nil && gumroadResponse.Success { + result := &ValidateResult{ + Plan: v1pb.PlanType_PRO, + Features: getDefaultFeatures(v1pb.PlanType_PRO), + Seats: -1, // Unlimited seats for Gumroad subscriptions + } + // For subscription products, set expiration based on recurrence + if gumroadResponse.Purchase != nil && gumroadResponse.Purchase.Recurrence != "" { + // Subscription is active, set expiration to 1 year from now + result.ExpiresTime = time.Now().AddDate(1, 0, 0) + } + return result, nil } - if validateResponse.Valid { + + // Try to validate the license key with LemonSqueezy (fallback for existing customers). + lemonsqueezyResponse, err := lemonsqueezy.ValidateLicenseKey(licenseKey, "") + if err == nil && lemonsqueezyResponse.Valid { result := &ValidateResult{ Plan: v1pb.PlanType_PRO, Features: getDefaultFeatures(v1pb.PlanType_PRO), } - if validateResponse.LicenseKey.ExpiresAt != nil && *validateResponse.LicenseKey.ExpiresAt != "" { - expiresTime, err := time.Parse(time.RFC3339Nano, *validateResponse.LicenseKey.ExpiresAt) + if lemonsqueezyResponse.LicenseKey.ExpiresAt != nil && *lemonsqueezyResponse.LicenseKey.ExpiresAt != "" { + expiresTime, err := time.Parse(time.RFC3339Nano, *lemonsqueezyResponse.LicenseKey.ExpiresAt) if err != nil { return nil, errors.Wrap(err, "failed to parse license key expires time") }