Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
49.02% |
50 / 102 |
|
40.00% |
6 / 15 |
CRAP | |
0.00% |
0 / 1 |
| LicenseManager | |
49.02% |
50 / 102 |
|
40.00% |
6 / 15 |
536.99 | |
0.00% |
0 / 1 |
| isLicensed | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
11.30 | |||
| key | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
7.73 | |||
| secret | |
40.00% |
2 / 5 |
|
0.00% |
0 / 1 |
7.46 | |||
| siteId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| domain | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| status | |
18.18% |
2 / 11 |
|
0.00% |
0 / 1 |
77.27 | |||
| freemiusPlanId | |
50.00% |
4 / 8 |
|
0.00% |
0 / 1 |
8.12 | |||
| store | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| forget | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| activateViaSdk | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
5.67 | |||
| isSdkLoaded | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| freemiusSiteSecret | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
42 | |||
| freemiusSiteId | |
22.22% |
2 / 9 |
|
0.00% |
0 / 1 |
22.94 | |||
| sdkHasSite | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
| visitorIdHash | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * License Manager (CF16/F56 — Freemius-backed). |
| 4 | * |
| 5 | * Two storage backends, in priority order: |
| 6 | * 1. Freemius SDK storage (FS_Plugin_License) — when SDK is loaded. |
| 7 | * 2. Legacy wp_options storage (swapads_client_license_*) — fallback |
| 8 | * for installs predating the SDK integration. Kept for backwards |
| 9 | * compat; once LicenseManager::store() is called by the new |
| 10 | * SettingsPage (post-CF16), the legacy data is migrated forward |
| 11 | * and not touched again. |
| 12 | * |
| 13 | * @package SwapAds\Client\License |
| 14 | * @since 1.0.0 (legacy) |
| 15 | * @since 1.1.0 (Freemius fallback + migration path) |
| 16 | */ |
| 17 | |
| 18 | declare(strict_types=1); |
| 19 | |
| 20 | namespace SwapAds\Client\License; |
| 21 | |
| 22 | /** |
| 23 | * Class LicenseManager. |
| 24 | * |
| 25 | * @since 1.0.0 |
| 26 | */ |
| 27 | final class LicenseManager |
| 28 | { |
| 29 | public const OPTION_KEY = 'swapads_client_license_key'; |
| 30 | public const OPTION_SECRET = 'swapads_client_license_secret'; |
| 31 | public const OPTION_SITE_ID = 'swapads_client_site_id'; |
| 32 | public const OPTION_DOMAIN = 'swapads_client_site_domain'; |
| 33 | public const OPTION_STATUS = 'swapads_client_license_status'; |
| 34 | public const OPTION_FREEMIUS_PLAN = 'swapads_client_freemius_plan'; |
| 35 | public const OPTION_FREEMIUS_LICENSE = 'swapads_client_freemius_license_id'; |
| 36 | |
| 37 | /** |
| 38 | * Check if the client is licensed. |
| 39 | * |
| 40 | * Returns true when either (a) a Freemius license exists in SDK storage, |
| 41 | * or (b) the legacy wp_options key is set. |
| 42 | * |
| 43 | * @since 1.1.0 Reads Freemius first, falls back to legacy. |
| 44 | * |
| 45 | * @return bool |
| 46 | */ |
| 47 | public static function isLicensed(): bool |
| 48 | { |
| 49 | if (function_exists('swa_fs')) { |
| 50 | $sdkLicense = swa_fs()->_get_license(); |
| 51 | if (is_object($sdkLicense) && property_exists($sdkLicense, 'id') && $sdkLicense->id > 0) { |
| 52 | return true; |
| 53 | } |
| 54 | } |
| 55 | // CF17.1: a site is "licensed" when either: |
| 56 | // (a) a paid license key is stored, OR |
| 57 | // (b) the server secret has been fetched from /public-key (free plan) |
| 58 | if ((string) get_option(self::OPTION_KEY, '') !== '') { |
| 59 | return true; |
| 60 | } |
| 61 | if ((string) get_option(self::OPTION_SECRET, '') !== '') { |
| 62 | return true; |
| 63 | } |
| 64 | return false; |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Get the license key. |
| 69 | * |
| 70 | * @since 1.1.0 Reads Freemius first. |
| 71 | * |
| 72 | * @return string |
| 73 | */ |
| 74 | public static function key(): string |
| 75 | { |
| 76 | if (function_exists('swa_fs')) { |
| 77 | $sdkLicense = swa_fs()->_get_license(); |
| 78 | if (is_object($sdkLicense) && !empty($sdkLicense->secret_key)) { |
| 79 | return (string) $sdkLicense->secret_key; |
| 80 | } |
| 81 | } |
| 82 | $stored = (string) get_option(self::OPTION_KEY, ''); |
| 83 | if ($stored !== '') { |
| 84 | return $stored; |
| 85 | } |
| 86 | // CF17.1 fallback: use FS_Site.id as a synthetic license key for free-plan sites. |
| 87 | $fsSiteId = self::freemiusSiteId(); |
| 88 | if ($fsSiteId > 0) { |
| 89 | return 'fs_site_' . $fsSiteId; |
| 90 | } |
| 91 | return ''; |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Get the license secret (HMAC shared secret for server calls). |
| 96 | * |
| 97 | * @since 1.1.0 Reads Freemius first. |
| 98 | * |
| 99 | * @return string |
| 100 | */ |
| 101 | public static function secret(): string |
| 102 | { |
| 103 | // Paid license path: SDK stores the secret in the license object. |
| 104 | if (function_exists('swa_fs')) { |
| 105 | $sdkLicense = swa_fs()->_get_license(); |
| 106 | if (is_object($sdkLicense) && !empty($sdkLicense->secret_key)) { |
| 107 | return (string) $sdkLicense->secret_key; |
| 108 | } |
| 109 | } |
| 110 | // CF17.1: OPTION_SECRET holds SWAPADS_SERVER_SECRET (set by manual |
| 111 | // entry or by FreemiusAutoActivator::ensureServerSecret() via /public-key). |
| 112 | return (string) get_option(self::OPTION_SECRET, ''); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Get the site_id assigned by server. |
| 117 | * |
| 118 | * @return int |
| 119 | */ |
| 120 | public static function siteId(): int |
| 121 | { |
| 122 | return (int) get_option(self::OPTION_SITE_ID, 0); |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * Get the registered domain. |
| 127 | * |
| 128 | * @return string |
| 129 | */ |
| 130 | public static function domain(): string |
| 131 | { |
| 132 | return (string) get_option(self::OPTION_DOMAIN, ''); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Get the license status (active / expired / cancelled / etc). |
| 137 | * |
| 138 | * Reads from the SDK if available, falls back to legacy. |
| 139 | * |
| 140 | * @since 1.1.0 |
| 141 | * |
| 142 | * @return string |
| 143 | */ |
| 144 | public static function status(): string |
| 145 | { |
| 146 | if (function_exists('swa_fs')) { |
| 147 | $sdkLicense = swa_fs()->_get_license(); |
| 148 | if (is_object($sdkLicense) && property_exists($sdkLicense, 'id') && $sdkLicense->id > 0) { |
| 149 | // Determine status from FS_Plugin_License fields. |
| 150 | $isCancelled = property_exists($sdkLicense, 'is_cancelled') && $sdkLicense->is_cancelled; |
| 151 | $expiration = (string) (property_exists($sdkLicense, 'expiration') ? $sdkLicense->expiration : ''); |
| 152 | if ($isCancelled) { |
| 153 | return 'cancelled'; |
| 154 | } |
| 155 | if ($expiration !== '' && $expiration !== 'never' && strtotime($expiration) < time()) { |
| 156 | return 'expired'; |
| 157 | } |
| 158 | return 'active'; |
| 159 | } |
| 160 | } |
| 161 | return (string) get_option(self::OPTION_STATUS, ''); |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Get the Freemius plan id for the current license, if any. |
| 166 | * |
| 167 | * Y2 will use this for plan-based feature gating. In Y1 we read it |
| 168 | * for visibility / display only — actual gating happens on the |
| 169 | * server side using pricing_mode column. |
| 170 | * |
| 171 | * @since 1.1.0 |
| 172 | * |
| 173 | * @return int 0 if no plan (free), else plan id. |
| 174 | */ |
| 175 | public static function freemiusPlanId(): int |
| 176 | { |
| 177 | // Read legacy first (we own this), then SDK as fallback. |
| 178 | $stored = (int) get_option(self::OPTION_FREEMIUS_PLAN, 0); |
| 179 | if ($stored > 0) { |
| 180 | return $stored; |
| 181 | } |
| 182 | if (function_exists('swa_fs')) { |
| 183 | $sdkLicense = swa_fs()->_get_license(); |
| 184 | if (is_object($sdkLicense) && !empty($sdkLicense->plan_id)) { |
| 185 | return (int) $sdkLicense->plan_id; |
| 186 | } |
| 187 | } |
| 188 | return 0; |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Store a license activation response from the server (legacy direct activation). |
| 193 | * |
| 194 | * For new Freemius-activated licenses, prefer activateViaSdk() instead. |
| 195 | * |
| 196 | * @param string $licenseKey |
| 197 | * @param string $licenseSecret |
| 198 | * @param int $siteId |
| 199 | * @param string $domain |
| 200 | * @param string $status |
| 201 | */ |
| 202 | public static function store(string $licenseKey, string $licenseSecret, int $siteId, string $domain, string $status): void |
| 203 | { |
| 204 | update_option(self::OPTION_KEY, $licenseKey); |
| 205 | update_option(self::OPTION_SECRET, $licenseSecret); |
| 206 | update_option(self::OPTION_SITE_ID, $siteId); |
| 207 | update_option(self::OPTION_DOMAIN, $domain); |
| 208 | update_option(self::OPTION_STATUS, $status); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Forget the stored license (deactivate). |
| 213 | */ |
| 214 | public static function forget(): void |
| 215 | { |
| 216 | delete_option(self::OPTION_KEY); |
| 217 | delete_option(self::OPTION_SECRET); |
| 218 | delete_option(self::OPTION_SITE_ID); |
| 219 | delete_option(self::OPTION_DOMAIN); |
| 220 | delete_option(self::OPTION_STATUS); |
| 221 | delete_option(self::OPTION_FREEMIUS_PLAN); |
| 222 | delete_option(self::OPTION_FREEMIUS_LICENSE); |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Activate the plugin license via the embedded Freemius SDK. |
| 227 | * |
| 228 | * When the SDK is present, this delegates to fs_activate() and the SDK |
| 229 | * handles the communication with the Freemius API. In Y1 the default |
| 230 | * plan is free ($0/month); operators only get a license after purchase. |
| 231 | * |
| 232 | * In development mode (WP_FS__DEV_MODE defined), the SDK is shortcut to |
| 233 | * a fake activation so the rest of the plugin can be exercised locally. |
| 234 | * |
| 235 | * @since 1.1.0 |
| 236 | * |
| 237 | * @return bool true on success, false if the SDK is not loaded. |
| 238 | */ |
| 239 | public static function activateViaSdk(): bool |
| 240 | { |
| 241 | if (!function_exists('swa_fs')) { |
| 242 | return false; |
| 243 | } |
| 244 | $sdk = swa_fs(); |
| 245 | if (method_exists($sdk, 'activate')) { |
| 246 | $sdk->activate(); |
| 247 | } |
| 248 | return true; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Confirm that the Freemius SDK has been loaded successfully. |
| 253 | * |
| 254 | * @since 1.1.0 |
| 255 | * |
| 256 | * @return bool |
| 257 | */ |
| 258 | public static function isSdkLoaded(): bool |
| 259 | { |
| 260 | return function_exists('swa_fs'); |
| 261 | } |
| 262 | |
| 263 | |
| 264 | /** |
| 265 | * CF17.1: Get the FS_Site.secret_key for free-plan installs. |
| 266 | * |
| 267 | * Returns the Freemius site install secret, used as the HMAC secret |
| 268 | * when no paid license is available. Empty string when SDK not loaded |
| 269 | * or site not registered. |
| 270 | * |
| 271 | * @since 1.3.0 |
| 272 | * |
| 273 | * @return string |
| 274 | */ |
| 275 | public static function freemiusSiteSecret(): string |
| 276 | { |
| 277 | if (!function_exists('swa_fs')) { |
| 278 | return ''; |
| 279 | } |
| 280 | $sdk = swa_fs(); |
| 281 | if (!is_object($sdk) || !method_exists($sdk, 'get_site')) { |
| 282 | return ''; |
| 283 | } |
| 284 | $site = $sdk->get_site(); |
| 285 | if (!is_object($site) || empty($site->secret_key)) { |
| 286 | return ''; |
| 287 | } |
| 288 | return (string) $site->secret_key; |
| 289 | } |
| 290 | |
| 291 | /** |
| 292 | * CF17.1: Get the FS_Site.id for free-plan installs. |
| 293 | * |
| 294 | * @since 1.3.0 |
| 295 | * |
| 296 | * @return int |
| 297 | */ |
| 298 | public static function freemiusSiteId(): int |
| 299 | { |
| 300 | if (!function_exists('swa_fs')) { |
| 301 | return 0; |
| 302 | } |
| 303 | $sdk = swa_fs(); |
| 304 | if (!is_object($sdk) || !method_exists($sdk, 'get_site')) { |
| 305 | return 0; |
| 306 | } |
| 307 | $site = $sdk->get_site(); |
| 308 | if (!is_object($site) || empty($site->id)) { |
| 309 | return 0; |
| 310 | } |
| 311 | return (int) $site->id; |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * CF17.1: Check whether the SDK has a registered site (free or paid). |
| 316 | * |
| 317 | * Distinct from sdkHasLicense() (CF17): a registered site without a |
| 318 | * paid license can still authenticate with the server via FS_Site. |
| 319 | * |
| 320 | * @since 1.3.0 |
| 321 | * |
| 322 | * @return bool |
| 323 | */ |
| 324 | public static function sdkHasSite(): bool |
| 325 | { |
| 326 | if (!function_exists('swa_fs')) { |
| 327 | return false; |
| 328 | } |
| 329 | $sdk = swa_fs(); |
| 330 | if (!is_object($sdk)) { |
| 331 | return false; |
| 332 | } |
| 333 | // Freemius returns false when site is in opt-in (not yet allowed). |
| 334 | if (method_exists($sdk, 'is_registered') && !$sdk->is_registered()) { |
| 335 | return false; |
| 336 | } |
| 337 | return self::freemiusSiteSecret() !== ''; |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Generate a unique visitor id and SHA-256 hash it for privacy. |
| 342 | * |
| 343 | * @return string 64-char hex hash. |
| 344 | */ |
| 345 | public static function visitorIdHash(): string |
| 346 | { |
| 347 | if (empty($_COOKIE['swapads_visitor_id'] ?? '')) { |
| 348 | $vid = bin2hex(random_bytes(16)); |
| 349 | setcookie('swapads_visitor_id', $vid, [ |
| 350 | 'expires' => time() + 86400 * 365, |
| 351 | 'path' => '/', |
| 352 | 'secure' => is_ssl(), |
| 353 | 'httponly' => true, |
| 354 | 'samesite' => 'Lax', |
| 355 | ]); |
| 356 | $_COOKIE['swapads_visitor_id'] = $vid; |
| 357 | } |
| 358 | return hash('sha256', (string) $_COOKIE['swapads_visitor_id']); |
| 359 | } |
| 360 | } |