diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b32bf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor/ +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db +.ironlicensing/ diff --git a/README.md b/README.md index f61425f..856027e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,310 @@ -# ironlicensing-go -IronLicensing SDK for Go - Software licensing and activation +# IronLicensing Go SDK + +Official Go SDK for [IronLicensing](https://ironlicensing.com) - Software licensing and activation for your applications. + +## Installation + +```bash +go get github.com/IronServices/ironlicensing-go +``` + +## Quick Start + +### Using Global Client + +```go +package main + +import ( + "fmt" + "log" + + licensing "github.com/IronServices/ironlicensing-go" +) + +func main() { + // Initialize the global client + err := licensing.Init("pk_live_your_public_key", "your-product-slug") + if err != nil { + log.Fatal(err) + } + + // Validate a license + result := licensing.Validate("IRON-XXXX-XXXX-XXXX-XXXX") + if result.Valid { + fmt.Println("License is valid!") + fmt.Printf("Status: %s\n", result.License.Status) + } else { + fmt.Printf("Validation failed: %s\n", result.Error) + } + + // Check for features + if licensing.HasFeature("premium") { + fmt.Println("Premium features enabled!") + } +} +``` + +### Using Client Instance + +```go +package main + +import ( + "context" + "fmt" + "log" + + licensing "github.com/IronServices/ironlicensing-go" +) + +func main() { + client, err := licensing.NewClient(licensing.Options{ + PublicKey: "pk_live_your_public_key", + ProductSlug: "your-product-slug", + Debug: true, + }) + if err != nil { + log.Fatal(err) + } + + // Activate with context + ctx := context.Background() + result := client.ActivateContext(ctx, "IRON-XXXX-XXXX-XXXX-XXXX", "My Machine") + + if result.Valid { + fmt.Printf("Activated! License type: %s\n", result.License.Type) + } +} +``` + +## Configuration + +```go +options := licensing.Options{ + PublicKey: "pk_live_xxx", // Required + ProductSlug: "your-product", // Required + APIBaseURL: "https://api.ironlicensing.com", // Default + Debug: false, // Enable debug logging + EnableOfflineCache: true, // Cache for offline use + CacheValidationMinutes: 60, // Cache duration + OfflineGraceDays: 7, // Offline grace period + HTTPTimeout: 30 * time.Second, // Request timeout +} +``` + +### Functional Options + +```go +err := licensing.Init("pk_live_xxx", "product-slug", + licensing.WithDebug(true), +) +``` + +## License Validation + +```go +// Simple validation +result := client.Validate("IRON-XXXX-XXXX-XXXX-XXXX") + +// With context for timeout/cancellation +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() +result := client.ValidateContext(ctx, "IRON-XXXX-XXXX-XXXX-XXXX") + +if result.Valid { + license := result.License + fmt.Printf("License: %s\n", license.Key) + fmt.Printf("Status: %s\n", license.Status) + fmt.Printf("Type: %s\n", license.Type) + fmt.Printf("Activations: %d/%d\n", license.CurrentActivations, license.MaxActivations) +} +``` + +## License Activation + +```go +// Simple activation (uses hostname as machine name) +result := client.Activate("IRON-XXXX-XXXX-XXXX-XXXX") + +// With custom machine name +result := client.ActivateContext(ctx, "IRON-XXXX-XXXX-XXXX-XXXX", "Production Server") + +if result.Valid { + fmt.Println("License activated successfully!") + + // View activations + for _, activation := range result.Activations { + fmt.Printf("- %s (%s)\n", activation.MachineName, activation.Platform) + } +} +``` + +## License Deactivation + +```go +if client.Deactivate() { + fmt.Println("License deactivated from this machine") +} +``` + +## Feature Checking + +```go +// Check if feature is available +if client.HasFeature("advanced-analytics") { + // Enable advanced analytics +} + +// Require feature (returns error if not available) +if err := client.RequireFeature("export-pdf"); err != nil { + fmt.Printf("Feature not available: %v\n", err) + return +} + +// Get feature details +feature := client.GetFeature("max-users") +if feature != nil { + fmt.Printf("Feature: %s - %s\n", feature.Name, feature.Description) +} +``` + +## Trial Management + +```go +result := client.StartTrial("user@example.com") +if result.Valid { + fmt.Println("Trial started!") + fmt.Printf("Trial key: %s\n", result.License.Key) + if result.License.ExpiresAt != nil { + fmt.Printf("Expires: %s\n", result.License.ExpiresAt.Format(time.RFC3339)) + } +} +``` + +## In-App Purchase + +```go +// Get available tiers +tiers := client.GetTiers() +for _, tier := range tiers { + fmt.Printf("%s - $%.2f %s\n", tier.Name, tier.Price, tier.Currency) +} + +// Start checkout +checkout := client.StartPurchase("tier-id", "user@example.com") +if checkout.Success { + fmt.Printf("Checkout URL: %s\n", checkout.CheckoutURL) + // Open URL in browser for user to complete purchase +} +``` + +## License Status + +```go +// Get current license +license := client.License() +if license != nil { + fmt.Printf("Licensed to: %s\n", license.Email) +} + +// Check status +status := client.Status() +switch status { +case licensing.StatusValid: + fmt.Println("License is valid") +case licensing.StatusExpired: + fmt.Println("License has expired") +case licensing.StatusTrial: + fmt.Println("Running in trial mode") +case licensing.StatusNotActivated: + fmt.Println("No license activated") +default: + fmt.Printf("Status: %s\n", status) +} + +// Quick checks +if client.IsLicensed() { + fmt.Println("Application is licensed") +} + +if client.IsTrial() { + fmt.Println("Running in trial mode") +} +``` + +## License Types + +| Type | Description | +|------|-------------| +| `TypePerpetual` | One-time purchase, never expires | +| `TypeSubscription` | Recurring payment, expires if not renewed | +| `TypeTrial` | Time-limited trial license | + +## License Statuses + +| Status | Description | +|--------|-------------| +| `StatusValid` | License is valid and active | +| `StatusExpired` | License has expired | +| `StatusSuspended` | License temporarily suspended | +| `StatusRevoked` | License permanently revoked | +| `StatusTrial` | Active trial license | +| `StatusTrialExpired` | Trial period ended | +| `StatusNotActivated` | No license activated | + +## Thread Safety + +The client is thread-safe and can be used concurrently from multiple goroutines: + +```go +var wg sync.WaitGroup +for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if client.HasFeature("concurrent-feature") { + // Safe to call from multiple goroutines + } + }() +} +wg.Wait() +``` + +## Error Handling + +```go +// Validation errors +result := client.Validate(licenseKey) +if !result.Valid { + switch result.Error { + case "license_not_found": + fmt.Println("Invalid license key") + case "license_expired": + fmt.Println("Your license has expired") + case "max_activations_reached": + fmt.Println("No more activations available") + default: + fmt.Printf("Error: %s\n", result.Error) + } +} + +// Feature requirement errors +err := client.RequireFeature("premium") +if err != nil { + if lre, ok := err.(*licensing.LicenseRequiredError); ok { + fmt.Printf("Feature '%s' requires a valid license\n", lre.Feature) + } +} +``` + +## Machine ID + +The SDK automatically generates and persists a unique machine ID at `~/.ironlicensing/machine_id`. This ID is used for: +- Tracking activations per machine +- Preventing license sharing +- Offline validation + +## License + +MIT License - see LICENSE file for details. diff --git a/client.go b/client.go new file mode 100644 index 0000000..6c20c01 --- /dev/null +++ b/client.go @@ -0,0 +1,266 @@ +package ironlicensing + +import ( + "context" + "errors" + "fmt" + "sync" +) + +var ( + ErrNotInitialized = errors.New("ironlicensing: client not initialized") + ErrPublicKeyRequired = errors.New("ironlicensing: public key is required") + ErrProductSlugRequired = errors.New("ironlicensing: product slug is required") +) + +type LicenseRequiredError struct { + Feature string +} + +func (e *LicenseRequiredError) Error() string { + return fmt.Sprintf("Feature '%s' requires a valid license", e.Feature) +} + +type Client struct { + options Options + transport *Transport + currentLicense *License + licenseKey string + mu sync.RWMutex +} + +var globalClient *Client +var globalMu sync.RWMutex + +func NewClient(options Options) (*Client, error) { + if options.PublicKey == "" { + return nil, ErrPublicKeyRequired + } + if options.ProductSlug == "" { + return nil, ErrProductSlugRequired + } + opts := options.WithDefaults() + c := &Client{ + options: opts, + transport: NewTransport(opts.APIBaseURL, opts.PublicKey, opts.ProductSlug, opts.HTTPTimeout, opts.Debug), + } + if opts.Debug { + fmt.Println("[IronLicensing] Client initialized") + } + return c, nil +} + +func Init(publicKey, productSlug string, opts ...func(*Options)) error { + options := DefaultOptions() + options.PublicKey = publicKey + options.ProductSlug = productSlug + for _, opt := range opts { + opt(&options) + } + client, err := NewClient(options) + if err != nil { + return err + } + globalMu.Lock() + globalClient = client + globalMu.Unlock() + return nil +} + +func WithDebug(debug bool) func(*Options) { + return func(o *Options) { o.Debug = debug } +} + +func getGlobalClient() (*Client, error) { + globalMu.RLock() + defer globalMu.RUnlock() + if globalClient == nil { + return nil, ErrNotInitialized + } + return globalClient, nil +} + +func (c *Client) Validate(licenseKey string) LicenseResult { + return c.ValidateContext(context.Background(), licenseKey) +} + +func (c *Client) ValidateContext(ctx context.Context, licenseKey string) LicenseResult { + result := c.transport.Validate(ctx, licenseKey) + if result.Valid && result.License != nil { + c.mu.Lock() + c.currentLicense = result.License + c.licenseKey = licenseKey + c.mu.Unlock() + } + return result +} + +func (c *Client) Activate(licenseKey string) LicenseResult { + return c.ActivateContext(context.Background(), licenseKey, "") +} + +func (c *Client) ActivateContext(ctx context.Context, licenseKey, machineName string) LicenseResult { + result := c.transport.Activate(ctx, licenseKey, machineName) + if result.Valid && result.License != nil { + c.mu.Lock() + c.currentLicense = result.License + c.licenseKey = licenseKey + c.mu.Unlock() + } + return result +} + +func (c *Client) Deactivate() bool { + return c.DeactivateContext(context.Background()) +} + +func (c *Client) DeactivateContext(ctx context.Context) bool { + c.mu.RLock() + key := c.licenseKey + c.mu.RUnlock() + if key == "" { + return false + } + if c.transport.Deactivate(ctx, key) { + c.mu.Lock() + c.currentLicense = nil + c.licenseKey = "" + c.mu.Unlock() + return true + } + return false +} + +func (c *Client) StartTrial(email string) LicenseResult { + return c.StartTrialContext(context.Background(), email) +} + +func (c *Client) StartTrialContext(ctx context.Context, email string) LicenseResult { + result := c.transport.StartTrial(ctx, email) + if result.Valid && result.License != nil { + c.mu.Lock() + c.currentLicense = result.License + c.licenseKey = result.License.Key + c.mu.Unlock() + } + return result +} + +func (c *Client) HasFeature(featureKey string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + if c.currentLicense == nil { + return false + } + for _, f := range c.currentLicense.Features { + if f.Key == featureKey && f.Enabled { + return true + } + } + return false +} + +func (c *Client) RequireFeature(featureKey string) error { + if !c.HasFeature(featureKey) { + return &LicenseRequiredError{Feature: featureKey} + } + return nil +} + +func (c *Client) GetFeature(featureKey string) *Feature { + c.mu.RLock() + defer c.mu.RUnlock() + if c.currentLicense == nil { + return nil + } + for _, f := range c.currentLicense.Features { + if f.Key == featureKey { + return &f + } + } + return nil +} + +func (c *Client) License() *License { + c.mu.RLock() + defer c.mu.RUnlock() + return c.currentLicense +} + +func (c *Client) Status() LicenseStatus { + c.mu.RLock() + defer c.mu.RUnlock() + if c.currentLicense != nil { + return c.currentLicense.Status + } + return StatusNotActivated +} + +func (c *Client) IsLicensed() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.currentLicense != nil && (c.currentLicense.Status == StatusValid || c.currentLicense.Status == StatusTrial) +} + +func (c *Client) IsTrial() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.currentLicense != nil && (c.currentLicense.Status == StatusTrial || c.currentLicense.Type == TypeTrial) +} + +func (c *Client) GetTiers() []ProductTier { + return c.transport.GetTiers(context.Background()) +} + +func (c *Client) StartPurchase(tierID, email string) CheckoutResult { + return c.transport.StartCheckout(context.Background(), tierID, email) +} + +// Global functions +func Validate(licenseKey string) LicenseResult { + c, err := getGlobalClient() + if err != nil { + return LicenseResult{Valid: false, Error: err.Error()} + } + return c.Validate(licenseKey) +} + +func Activate(licenseKey string) LicenseResult { + c, err := getGlobalClient() + if err != nil { + return LicenseResult{Valid: false, Error: err.Error()} + } + return c.Activate(licenseKey) +} + +func Deactivate() bool { + c, err := getGlobalClient() + if err != nil { + return false + } + return c.Deactivate() +} + +func StartTrial(email string) LicenseResult { + c, err := getGlobalClient() + if err != nil { + return LicenseResult{Valid: false, Error: err.Error()} + } + return c.StartTrial(email) +} + +func HasFeature(featureKey string) bool { + c, err := getGlobalClient() + if err != nil { + return false + } + return c.HasFeature(featureKey) +} + +func RequireFeature(featureKey string) error { + c, err := getGlobalClient() + if err != nil { + return err + } + return c.RequireFeature(featureKey) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..38440c9 --- /dev/null +++ b/config.go @@ -0,0 +1,45 @@ +package ironlicensing + +import "time" + +// Options configures the IronLicensing client. +type Options struct { + PublicKey string + ProductSlug string + APIBaseURL string + Debug bool + EnableOfflineCache bool + CacheValidationMinutes int + OfflineGraceDays int + HTTPTimeout time.Duration +} + +// DefaultOptions returns the default configuration options. +func DefaultOptions() Options { + return Options{ + APIBaseURL: "https://api.ironlicensing.com", + Debug: false, + EnableOfflineCache: true, + CacheValidationMinutes: 60, + OfflineGraceDays: 7, + HTTPTimeout: 30 * time.Second, + } +} + +// WithDefaults returns the options with default values applied. +func (o Options) WithDefaults() Options { + defaults := DefaultOptions() + if o.APIBaseURL == "" { + o.APIBaseURL = defaults.APIBaseURL + } + if o.CacheValidationMinutes == 0 { + o.CacheValidationMinutes = defaults.CacheValidationMinutes + } + if o.OfflineGraceDays == 0 { + o.OfflineGraceDays = defaults.OfflineGraceDays + } + if o.HTTPTimeout == 0 { + o.HTTPTimeout = defaults.HTTPTimeout + } + return o +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1089c1e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/IronServices/ironlicensing-go + +go 1.21 + +require github.com/google/uuid v1.6.0 diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..574c5b8 --- /dev/null +++ b/transport.go @@ -0,0 +1,193 @@ +package ironlicensing + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/google/uuid" +) + +type Transport struct { + baseURL string + publicKey string + productSlug string + debug bool + httpClient *http.Client + machineID string +} + +func NewTransport(baseURL, publicKey, productSlug string, timeout time.Duration, debug bool) *Transport { + t := &Transport{ + baseURL: baseURL, + publicKey: publicKey, + productSlug: productSlug, + debug: debug, + httpClient: &http.Client{Timeout: timeout}, + } + t.machineID = t.getMachineID() + return t +} + +func (t *Transport) log(msg string) { + if t.debug { + fmt.Printf("[IronLicensing] %s\n", msg) + } +} + +func (t *Transport) getMachineID() string { + homeDir, _ := os.UserHomeDir() + idPath := filepath.Join(homeDir, ".ironlicensing", "machine_id") + if data, err := os.ReadFile(idPath); err == nil { + return string(data) + } + id := uuid.New().String() + os.MkdirAll(filepath.Dir(idPath), 0755) + os.WriteFile(idPath, []byte(id), 0644) + return id +} + +func (t *Transport) request(ctx context.Context, method, path string, body any) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + data, _ := json.Marshal(body) + reqBody = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, reqBody) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Public-Key", t.publicKey) + req.Header.Set("X-Product-Slug", t.productSlug) + return t.httpClient.Do(req) +} + +func (t *Transport) Validate(ctx context.Context, licenseKey string) LicenseResult { + t.log(fmt.Sprintf("Validating: %s...", licenseKey[:min(10, len(licenseKey))])) + resp, err := t.request(ctx, "POST", "/api/v1/validate", map[string]string{ + "licenseKey": licenseKey, + "machineId": t.machineID, + }) + if err != nil { + return LicenseResult{Valid: false, Error: err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 200 { + var result LicenseResult + json.Unmarshal(body, &result) + return result + } + var errResp struct{ Error string } + json.Unmarshal(body, &errResp) + return LicenseResult{Valid: false, Error: errResp.Error} +} + +func (t *Transport) Activate(ctx context.Context, licenseKey, machineName string) LicenseResult { + t.log(fmt.Sprintf("Activating: %s...", licenseKey[:min(10, len(licenseKey))])) + if machineName == "" { + machineName, _ = os.Hostname() + } + resp, err := t.request(ctx, "POST", "/api/v1/activate", map[string]string{ + "licenseKey": licenseKey, + "machineId": t.machineID, + "machineName": machineName, + "platform": runtime.GOOS, + }) + if err != nil { + return LicenseResult{Valid: false, Error: err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 200 { + var result LicenseResult + json.Unmarshal(body, &result) + return result + } + var errResp struct{ Error string } + json.Unmarshal(body, &errResp) + return LicenseResult{Valid: false, Error: errResp.Error} +} + +func (t *Transport) Deactivate(ctx context.Context, licenseKey string) bool { + resp, err := t.request(ctx, "POST", "/api/v1/deactivate", map[string]string{ + "licenseKey": licenseKey, + "machineId": t.machineID, + }) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 +} + +func (t *Transport) StartTrial(ctx context.Context, email string) LicenseResult { + t.log(fmt.Sprintf("Starting trial for: %s", email)) + resp, err := t.request(ctx, "POST", "/api/v1/trial", map[string]string{ + "email": email, + "machineId": t.machineID, + }) + if err != nil { + return LicenseResult{Valid: false, Error: err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 200 { + var result LicenseResult + json.Unmarshal(body, &result) + return result + } + var errResp struct{ Error string } + json.Unmarshal(body, &errResp) + return LicenseResult{Valid: false, Error: errResp.Error} +} + +func (t *Transport) GetTiers(ctx context.Context) []ProductTier { + resp, err := t.request(ctx, "GET", "/api/v1/tiers", nil) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode == 200 { + var result struct{ Tiers []ProductTier } + json.NewDecoder(resp.Body).Decode(&result) + return result.Tiers + } + return nil +} + +func (t *Transport) StartCheckout(ctx context.Context, tierID, email string) CheckoutResult { + resp, err := t.request(ctx, "POST", "/api/v1/checkout", map[string]string{ + "tierId": tierID, + "email": email, + }) + if err != nil { + return CheckoutResult{Success: false, Error: err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 200 { + var result CheckoutResult + json.Unmarshal(body, &result) + result.Success = true + return result + } + var errResp struct{ Error string } + json.Unmarshal(body, &errResp) + return CheckoutResult{Success: false, Error: errResp.Error} +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..1048b23 --- /dev/null +++ b/types.go @@ -0,0 +1,94 @@ +// Package ironlicensing provides an SDK for software licensing. +package ironlicensing + +import "time" + +// LicenseStatus represents the status of a license. +type LicenseStatus string + +const ( + StatusValid LicenseStatus = "valid" + StatusExpired LicenseStatus = "expired" + StatusSuspended LicenseStatus = "suspended" + StatusRevoked LicenseStatus = "revoked" + StatusInvalid LicenseStatus = "invalid" + StatusTrial LicenseStatus = "trial" + StatusTrialExpired LicenseStatus = "trial_expired" + StatusNotActivated LicenseStatus = "not_activated" + StatusUnknown LicenseStatus = "unknown" +) + +// LicenseType represents the type of license. +type LicenseType string + +const ( + TypePerpetual LicenseType = "perpetual" + TypeSubscription LicenseType = "subscription" + TypeTrial LicenseType = "trial" +) + +// Feature represents a feature in a license. +type Feature struct { + Key string `json:"key"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Description string `json:"description,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// License represents license information. +type License struct { + ID string `json:"id"` + Key string `json:"key"` + Status LicenseStatus `json:"status"` + Type LicenseType `json:"type"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Company string `json:"company,omitempty"` + Features []Feature `json:"features"` + MaxActivations int `json:"maxActivations"` + CurrentActivations int `json:"currentActivations"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + LastValidatedAt *time.Time `json:"lastValidatedAt,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Activation represents activation information. +type Activation struct { + ID string `json:"id"` + MachineID string `json:"machineId"` + MachineName string `json:"machineName,omitempty"` + Platform string `json:"platform,omitempty"` + ActivatedAt time.Time `json:"activatedAt"` + LastSeenAt time.Time `json:"lastSeenAt"` +} + +// LicenseResult represents the result of license validation. +type LicenseResult struct { + Valid bool `json:"valid"` + License *License `json:"license,omitempty"` + Activations []Activation `json:"activations,omitempty"` + Error string `json:"error,omitempty"` + Cached bool `json:"cached,omitempty"` +} + +// CheckoutResult represents the result of checkout. +type CheckoutResult struct { + Success bool `json:"success"` + CheckoutURL string `json:"checkoutUrl,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Error string `json:"error,omitempty"` +} + +// ProductTier represents a product tier for purchase. +type ProductTier struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Price float64 `json:"price"` + Currency string `json:"currency"` + BillingPeriod string `json:"billingPeriod,omitempty"` + Features []Feature `json:"features"` +}