Private/LicenseManager.ps1
|
# Module-level license cache — populated by Get-SPCLicenseInfo, cleared by Register-SPCLicense # and Disconnect-SPCTenant. Not persisted across PowerShell sessions. $script:SPCLicenseCache = $null $script:GumroadApiUrl = 'https://api.gumroad.com/v2/licenses/verify' $script:GumroadProductIds = @{ PRO = 'iHSmQlH-l22861DgO1u0AQ==' CONSULTANT = 'uMyAGbh6U5LWF_T8wur4bg==' } $script:LicenseCacheMaxAgeDays = 7 $script:LicenseFilePath = if ($IsWindows -or $PSVersionTable.PSVersion.Major -le 5) { Join-Path $env:APPDATA 'SPClean\license.lic' } else { Join-Path $HOME '.config/SPClean/license.lic' } function Invoke-GumroadVerifyInternal { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$LicenseKey, [Parameter(Mandatory)] [ValidateSet('PRO','CONSULTANT')] [string]$Tier, [bool]$IncrementUses = $true ) $productId = $script:GumroadProductIds[$Tier] $body = @{ product_id = $productId license_key = $LicenseKey increment_uses_count = $IncrementUses.ToString().ToLower() } try { $response = Invoke-RestMethod -Method POST -Uri $script:GumroadApiUrl ` -Body $body -ErrorAction Stop $purchasedAt = $null if ($response.purchase.created_at) { $purchasedAt = [datetime]::Parse($response.purchase.created_at) } return [PSCustomObject]@{ Success = [bool]$response.success Tier = $Tier Email = $response.purchase.email PurchasedAt = $purchasedAt Refunded = ($response.purchase.refunded -eq $true) -or ($response.purchase.chargebacked -eq $true) IsTesting = ($response.purchase.test -eq $true) } } catch { $statusCode = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } if ($statusCode -eq 404) { return [PSCustomObject]@{ Success = $false; Tier = $Tier } } throw ("ERR-LIC-NET-001: Cannot reach Gumroad API to verify license. " + "Check internet connection and try again. Detail: $($_.Exception.Message)") } } function Test-SPCLicenseKeyInternal { [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$LicenseKey ) $makeInvalid = { param([string]$Reason) [PSCustomObject]@{ IsValid = $false Tier = $null Email = $null PurchasedAt = $null IsTesting = $false FailureReason = $Reason } } # 1. Trim $key = $LicenseKey.Trim() # 2. Validate license format (Gumroad keys: XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX) if ($key -notmatch '^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{8}-[A-Fa-f0-9]{8}-[A-Fa-f0-9]{8}$') { $guid = [System.Guid]::empty if (-not [System.Guid]::TryParse($key, [ref]$guid)) { return (& $makeInvalid 'MalformedFormat') } } # 3. Try PRO $result = $null try { $result = Invoke-GumroadVerifyInternal -LicenseKey $key -Tier 'PRO' -IncrementUses $true } catch { return (& $makeInvalid 'NetworkError') } # 4. If PRO failed, try CONSULTANT if (-not $result.Success) { try { $result = Invoke-GumroadVerifyInternal -LicenseKey $key -Tier 'CONSULTANT' -IncrementUses $true } catch { return (& $makeInvalid 'NetworkError') } } # 5. Both tiers failed if (-not $result.Success) { return (& $makeInvalid 'InvalidKey') } # 6. Check refunded if ($result.Refunded) { return (& $makeInvalid 'Refunded') } # 7. Check expiration (365 days from purchase) if ($result.PurchasedAt) { $ageSincePurchase = ([datetime]::UtcNow - $result.PurchasedAt.ToUniversalTime()).TotalDays if ($ageSincePurchase -gt 365) { return (& $makeInvalid 'Expired') } } # 8. Valid return [PSCustomObject]@{ IsValid = $true Tier = $result.Tier Email = $result.Email PurchasedAt = $result.PurchasedAt IsTesting = $result.IsTesting FailureReason = $null } } function Get-SPCLicenseDataInternal { # Returns [PSCustomObject]@{ Status; LicData } # Status: 'Unlicensed' | 'Invalid' | 'Active' | 'Revoked' if (-not (Test-Path $script:LicenseFilePath)) { return [PSCustomObject]@{ Status = 'Unlicensed'; LicData = $null } } try { $json = Get-Content -Path $script:LicenseFilePath -Raw -Encoding UTF8 -ErrorAction Stop $data = $json | ConvertFrom-Json -ErrorAction Stop } catch { Write-Warning "SPClean: License file is malformed and could not be read." return [PSCustomObject]@{ Status = 'Invalid'; LicData = $null } } if ([string]::IsNullOrWhiteSpace($data.licenseKey) -or [string]::IsNullOrWhiteSpace($data.lastVerifiedAt) -or $data.tier -notin @('PRO', 'CONSULTANT')) { Write-Warning "SPClean: License file is missing or has invalid required fields." return [PSCustomObject]@{ Status = 'Invalid'; LicData = $null } } try { $lastVerified = [datetime]::Parse( $data.lastVerifiedAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) } catch { Write-Warning "SPClean: License file has invalid lastVerifiedAt timestamp." return [PSCustomObject]@{ Status = 'Invalid'; LicData = $null } } $ageDays = ([datetime]::UtcNow - $lastVerified).TotalDays # Expiration check based on purchase date (365 days max) if (-not [string]::IsNullOrWhiteSpace($data.purchasedAt)) { try { $purchasedDate = [datetime]::Parse( $data.purchasedAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) if (([datetime]::UtcNow - $purchasedDate.ToUniversalTime()).TotalDays -gt 365) { Remove-Item $script:LicenseFilePath -Force -ErrorAction SilentlyContinue return [PSCustomObject]@{ Status = 'Expired'; LicData = $null } } } catch {} } if ($ageDays -lt $script:LicenseCacheMaxAgeDays) { return [PSCustomObject]@{ Status = 'Active'; LicData = $data } } # Re-verify (cache >= 7 days old) try { $verify = Invoke-GumroadVerifyInternal -LicenseKey $data.licenseKey -Tier $data.tier -IncrementUses $false if (-not $verify.Success) { Remove-Item $script:LicenseFilePath -Force -ErrorAction SilentlyContinue return [PSCustomObject]@{ Status = 'Invalid'; LicData = $null } } if ($verify.Refunded) { Remove-Item $script:LicenseFilePath -Force -ErrorAction SilentlyContinue return [PSCustomObject]@{ Status = 'Revoked'; LicData = $null } } $data.lastVerifiedAt = [datetime]::UtcNow.ToString('o') $data | ConvertTo-Json | Set-Content -Path $script:LicenseFilePath -Encoding UTF8 -ErrorAction SilentlyContinue return [PSCustomObject]@{ Status = 'Active'; LicData = $data } } catch { $ageDaysRounded = [Math]::Floor($ageDays) Write-Warning "Cannot re-verify license (offline). Using cached status (last verified $ageDaysRounded days ago)." return [PSCustomObject]@{ Status = 'Active'; LicData = $data } } } function Assert-SPCProLicense { param([Parameter(Mandatory)] [string]$Feature) $info = Get-SPCLicenseInfo if ($info.Status -eq 'Active' -and $info.Tier -in @('PRO', 'CONSULTANT')) { if ($info.Tier -eq 'PRO') { $currentTenant = $script:SPCContext.TenantName if (-not $currentTenant) { throw "ERR-LIC-005: You must connect to a SharePoint tenant using Connect-SPCTenant first." } if ($info.RegisteredTenant -ne $currentTenant) { throw "ERR-LIC-005: This PRO license is registered to '$($info.RegisteredTenant)' and cannot be used on '$currentTenant'. Please upgrade to CONSULTANT for multi-tenant support." } } return } throw "ERR-LIC-003: '$Feature' requires a Pro or Consultant license. → Purchase Pro: https://ngochung47.gumroad.com/l/spclean-pro or Consultant: https://ngochung47.gumroad.com/l/spclean-consultant" } function Assert-SPCConsultantLicense { param([Parameter(Mandatory)] [string]$Feature) $info = Get-SPCLicenseInfo if ($info.Status -eq 'Active' -and $info.Tier -eq 'CONSULTANT') { return } throw "ERR-LIC-004: '$Feature' requires a Consultant license. → Upgrade: https://ngochung47.gumroad.com/l/spclean-consultant" } |