Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
31.45% |
39 / 124 |
|
30.77% |
4 / 13 |
CRAP | |
0.00% |
0 / 1 |
| FreemiusAutoActivator | |
31.45% |
39 / 124 |
|
30.77% |
4 / 13 |
727.57 | |
0.00% |
0 / 1 |
| register | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| scheduleResyncCron | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| shouldAttempt | |
25.00% |
2 / 8 |
|
0.00% |
0 / 1 |
10.75 | |||
| sdkHasLicense | |
18.18% |
2 / 11 |
|
0.00% |
0 / 1 |
43.05 | |||
| sdkHasSite | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| maybeAutoActivate | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| forceResync | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| runActivation | |
16.13% |
10 / 62 |
|
0.00% |
0 / 1 |
69.00 | |||
| getStatus | |
40.00% |
2 / 5 |
|
0.00% |
0 / 1 |
4.94 | |||
| getLastResult | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| retryNow | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| ensureServerSecret | |
80.00% |
16 / 20 |
|
0.00% |
0 / 1 |
9.65 | |||
| looksLikeServerSecret | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Freemius Auto-Activation (CF17 — client side). |
| 4 | * |
| 5 | * Closes the gap from CF16: the SDK loads + operator opts in, but the |
| 6 | * server never knows about this site. This class bridges that gap by |
| 7 | * calling POST /v1/license/activate automatically after the SDK has |
| 8 | * registered a license. |
| 9 | * |
| 10 | * Flow: |
| 11 | * 1. Operator installs + activates plugin |
| 12 | * 2. SDK loads, shows opt-in (or skips in whitelabel/dev mode) |
| 13 | * 3. Once `swa_fs()->is_registered()` is true AND a license exists, |
| 14 | * we POST {site_url} to /v1/license/activate on the server |
| 15 | * 4. Server returns {license_id, status: probation|active|...} |
| 16 | * 5. We cache result in wp_options so subsequent heartbeats don't |
| 17 | * hammer the server |
| 18 | * |
| 19 | * Caching: |
| 20 | * - Successful activation cached for 6h (re-syncs on heartbeat) |
| 21 | * - Failed activation cached for 1h (retry sooner, but not constantly) |
| 22 | * - Manual `retry now` clears cache and re-runs immediately |
| 23 | * |
| 24 | * Error handling: |
| 25 | * - Network errors → transient, retry next heartbeat |
| 26 | * - 401 (bad license) → permanent, mark as `failed` and surface in UI |
| 27 | * - 429 (rate limit) → back off per server's reset_at header |
| 28 | * |
| 29 | * @package SwapAds\Client\License |
| 30 | * @since 1.2.0 |
| 31 | */ |
| 32 | |
| 33 | declare(strict_types=1); |
| 34 | |
| 35 | namespace SwapAds\Client\License; |
| 36 | |
| 37 | use SwapAds\Client\Security\EncryptedOption; |
| 38 | |
| 39 | use SwapAds\Client\Api\RestClient; |
| 40 | |
| 41 | /** |
| 42 | * Class FreemiusAutoActivator. |
| 43 | * |
| 44 | * @since 1.2.0 |
| 45 | */ |
| 46 | final class FreemiusAutoActivator |
| 47 | { |
| 48 | /** |
| 49 | * WP option holding the last activation result. |
| 50 | */ |
| 51 | public const OPTION_LAST_ACTIVATION = 'swapads_client_last_fs_activation'; |
| 52 | |
| 53 | /** |
| 54 | * WP option holding the last activation attempt timestamp. |
| 55 | */ |
| 56 | public const OPTION_LAST_ATTEMPT_AT = 'swapads_client_last_fs_attempt_at'; |
| 57 | |
| 58 | /** |
| 59 | * WP option holding the cached activation status (active|failed|pending). |
| 60 | */ |
| 61 | public const OPTION_STATUS = 'swapads_client_fs_activation_status'; |
| 62 | |
| 63 | /** |
| 64 | * WP option holding the server's shared HMAC secret (CF17.1). |
| 65 | * |
| 66 | * This is distinct from LicenseManager::OPTION_SECRET (which holds the |
| 67 | * Freemius per-site license secret). The two are NEVER the same value: |
| 68 | * - Freemius license secret is per-site, given to the operator by Freemius. |
| 69 | * - Server HMAC secret is shared across the network, fetched from |
| 70 | * GET /wp-json/swapads-server/v1/public-key during auto-activation. |
| 71 | * |
| 72 | * Conflating them causes every HMAC signature to fail and the server |
| 73 | * never learns about the operator's license (symptom: client appears |
| 74 | * activated but server returns LICENSE_NOT_FOUND). |
| 75 | */ |
| 76 | public const OPTION_SERVER_SECRET = 'swapads_client_server_hmac_secret'; |
| 77 | |
| 78 | /** |
| 79 | * Time (s) between successful re-syncs. |
| 80 | */ |
| 81 | public const SUCCESS_CACHE_TTL = 6 * HOUR_IN_SECONDS; |
| 82 | |
| 83 | /** |
| 84 | * Time (s) between failed retries. |
| 85 | */ |
| 86 | public const FAILED_CACHE_TTL = HOUR_IN_SECONDS; |
| 87 | |
| 88 | /** |
| 89 | * Register WordPress hooks. |
| 90 | */ |
| 91 | public static function register(): void |
| 92 | { |
| 93 | // Plg-001 (2026-08-01): the previous implementation hooked a |
| 94 | // fictitious custom action which the Freemius SDK never emits, so |
| 95 | // auto-activation NEVER fired. The SDK does emit 'init' on every |
| 96 | // request *after* plugins_loaded completes, so we hook 'init' at |
| 97 | // priority 11 — AFTER WP core + our Plugin::init() — and gate on |
| 98 | // sdkHasSite() (which checks for an opted-in Freemius site). |
| 99 | add_action('init', [self::class, 'maybeAutoActivate'], 11); |
| 100 | // Periodic re-sync (every 12h via cron). |
| 101 | add_action('swapads_client_license_resync', [self::class, 'forceResync']); |
| 102 | add_action('init', [self::class, 'scheduleResyncCron']); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Schedule the resync cron if not already scheduled. |
| 107 | */ |
| 108 | public static function scheduleResyncCron(): void |
| 109 | { |
| 110 | if (!wp_next_scheduled('swapads_client_license_resync')) { |
| 111 | wp_schedule_event(time() + HOUR_IN_SECONDS, 'twicedaily', 'swapads_client_license_resync'); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Decide whether we should attempt auto-activation right now. |
| 117 | * |
| 118 | * Returns true when: |
| 119 | * - Freemius SDK is loaded |
| 120 | * - SDK reports an active license (operator has completed opt-in) |
| 121 | * - We have not attempted activation recently (cache TTL respected) |
| 122 | * |
| 123 | * @return bool |
| 124 | */ |
| 125 | public static function shouldAttempt(): bool |
| 126 | { |
| 127 | if (!function_exists('swa_fs')) { |
| 128 | return false; |
| 129 | } |
| 130 | if (!self::sdkHasLicense()) { |
| 131 | return false; |
| 132 | } |
| 133 | $lastAttempt = (int) get_option(self::OPTION_LAST_ATTEMPT_AT, 0); |
| 134 | $status = (string) get_option(self::OPTION_STATUS, ''); |
| 135 | $ttl = $status === 'active' ? self::SUCCESS_CACHE_TTL : self::FAILED_CACHE_TTL; |
| 136 | return (time() - $lastAttempt) >= $ttl; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Check whether the SDK currently has a license registered. |
| 141 | * |
| 142 | * Looks at swa_fs()->_get_license() and verifies id > 0 + has secret_key. |
| 143 | * |
| 144 | * @return bool |
| 145 | */ |
| 146 | public static function sdkHasLicense(): bool |
| 147 | { |
| 148 | if (!function_exists('swa_fs')) { |
| 149 | return false; |
| 150 | } |
| 151 | $sdk = swa_fs(); |
| 152 | if (!is_object($sdk) || !method_exists($sdk, '_get_license')) { |
| 153 | return false; |
| 154 | } |
| 155 | $license = $sdk->_get_license(); |
| 156 | if (!is_object($license)) { |
| 157 | return false; |
| 158 | } |
| 159 | $hasId = property_exists($license, 'id') && (int) $license->id > 0; |
| 160 | $hasSecret = property_exists($license, 'secret_key') && $license->secret_key !== ''; |
| 161 | return $hasId && $hasSecret; |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * CF17.1: Gate on Freemius site registration (free or paid plan). |
| 166 | * |
| 167 | * Distinct from sdkHasLicense(): free-plan installs (Y1) have no paid |
| 168 | * license but DO have a registered site (FS_Site). We use FS_Site.secret_key |
| 169 | * as the HMAC auth token for server calls. |
| 170 | * |
| 171 | * @since 1.3.0 |
| 172 | * |
| 173 | * @return bool |
| 174 | */ |
| 175 | public static function sdkHasSite(): bool |
| 176 | { |
| 177 | return LicenseManager::sdkHasSite(); |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Hook: attempt auto-activation after SDK opt-in. |
| 182 | */ |
| 183 | public static function maybeAutoActivate(): void |
| 184 | { |
| 185 | if (!self::shouldAttempt()) { |
| 186 | return; |
| 187 | } |
| 188 | self::runActivation(); |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Hook: forced re-sync (bypasses cache TTL). |
| 193 | */ |
| 194 | public static function forceResync(): void |
| 195 | { |
| 196 | if (!self::sdkHasSite()) { |
| 197 | return; |
| 198 | } |
| 199 | self::runActivation(); |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Perform the actual auto-activation call to the server. |
| 204 | * |
| 205 | * Posts {site_url} to /swapads-server/v1/license/activate. |
| 206 | * On success: caches status=active, license_id, expires_at. |
| 207 | * On failure: caches status=failed, error_code, error_message. |
| 208 | * |
| 209 | * @return array<string, mixed> Result array with 'success' key. |
| 210 | */ |
| 211 | public static function runActivation(): array |
| 212 | { |
| 213 | update_option(self::OPTION_LAST_ATTEMPT_AT, time()); |
| 214 | |
| 215 | if (!self::sdkHasSite()) { |
| 216 | $result = [ |
| 217 | 'success' => false, |
| 218 | 'error_code' => 'NO_SDK_LICENSE', |
| 219 | 'message' => 'Freemius SDK has no registered site yet (opt-in not completed).', |
| 220 | ]; |
| 221 | update_option(self::OPTION_LAST_ACTIVATION, $result); |
| 222 | update_option(self::OPTION_STATUS, 'failed'); |
| 223 | return $result; |
| 224 | } |
| 225 | |
| 226 | // CF17.1: if no server secret is stored yet (first-time install), |
| 227 | // fetch it from the public endpoint. This is required so the HMAC handshake |
| 228 | // can succeed. The endpoint returns the SHARED server secret (not per-site), |
| 229 | // so no auth is needed for it. |
| 230 | self::ensureServerSecret(); |
| 231 | |
| 232 | try { |
| 233 | $client = RestClient::fromOption(); |
| 234 | } catch (\Throwable $e) { |
| 235 | $result = [ |
| 236 | 'success' => false, |
| 237 | 'error_code' => 'CLIENT_INIT_FAILED', |
| 238 | 'message' => $e->getMessage(), |
| 239 | ]; |
| 240 | update_option(self::OPTION_LAST_ACTIVATION, $result); |
| 241 | update_option(self::OPTION_STATUS, 'failed'); |
| 242 | return $result; |
| 243 | } |
| 244 | |
| 245 | $siteUrl = (string) (function_exists('home_url') ? home_url('/') : ''); |
| 246 | if ($siteUrl === '') { |
| 247 | $result = [ |
| 248 | 'success' => false, |
| 249 | 'error_code' => 'NO_SITE_URL', |
| 250 | 'message' => 'home_url() returned empty; cannot register site.', |
| 251 | ]; |
| 252 | update_option(self::OPTION_LAST_ACTIVATION, $result); |
| 253 | update_option(self::OPTION_STATUS, 'failed'); |
| 254 | return $result; |
| 255 | } |
| 256 | |
| 257 | try { |
| 258 | $response = $client->post('/v1/license/activate', ['site_url' => $siteUrl]); |
| 259 | } catch (\Throwable $e) { |
| 260 | $result = [ |
| 261 | 'success' => false, |
| 262 | 'error_code' => 'NETWORK_ERROR', |
| 263 | 'message' => $e->getMessage(), |
| 264 | ]; |
| 265 | update_option(self::OPTION_LAST_ACTIVATION, $result); |
| 266 | update_option(self::OPTION_STATUS, 'failed'); |
| 267 | return $result; |
| 268 | } |
| 269 | |
| 270 | if (!is_array($response)) { |
| 271 | $result = [ |
| 272 | 'success' => false, |
| 273 | 'error_code' => 'INVALID_RESPONSE', |
| 274 | 'message' => 'Server returned non-array response.', |
| 275 | ]; |
| 276 | update_option(self::OPTION_LAST_ACTIVATION, $result); |
| 277 | update_option(self::OPTION_STATUS, 'failed'); |
| 278 | return $result; |
| 279 | } |
| 280 | |
| 281 | $success = !empty($response['success']); |
| 282 | update_option(self::OPTION_STATUS, $success ? 'active' : 'failed'); |
| 283 | |
| 284 | // On success, also persist site_id + status into LicenseManager for fast lookups. |
| 285 | if ($success && isset($response['license_id'])) { |
| 286 | LicenseManager::store( |
| 287 | LicenseManager::key(), |
| 288 | LicenseManager::secret(), |
| 289 | (int) $response['license_id'], |
| 290 | (string) parse_url($siteUrl, PHP_URL_HOST), |
| 291 | (string) ($response['status'] ?? 'probation') |
| 292 | ); |
| 293 | } |
| 294 | |
| 295 | update_option(self::OPTION_LAST_ACTIVATION, $response); |
| 296 | return $response; |
| 297 | } |
| 298 | |
| 299 | /** |
| 300 | * Get the current activation status for UI display. |
| 301 | * |
| 302 | * @return string One of: 'unknown', 'active', 'pending', 'failed'. |
| 303 | */ |
| 304 | public static function getStatus(): string |
| 305 | { |
| 306 | if (!function_exists('swa_fs')) { |
| 307 | return 'unknown'; |
| 308 | } |
| 309 | if (!self::sdkHasSite()) { |
| 310 | return 'pending'; |
| 311 | } |
| 312 | return (string) get_option(self::OPTION_STATUS, 'pending'); |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Get the last activation result for UI display. |
| 317 | * |
| 318 | * @return array<string, mixed>|null |
| 319 | */ |
| 320 | public static function getLastResult(): ?array |
| 321 | { |
| 322 | $v = get_option(self::OPTION_LAST_ACTIVATION, null); |
| 323 | return is_array($v) ? $v : null; |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Manually clear the activation cache and force re-attempt. |
| 328 | * |
| 329 | * @return array<string, mixed> Result of the activation attempt. |
| 330 | */ |
| 331 | public static function retryNow(): array |
| 332 | { |
| 333 | delete_option(self::OPTION_LAST_ATTEMPT_AT); |
| 334 | delete_option(self::OPTION_STATUS); |
| 335 | return self::runActivation(); |
| 336 | } |
| 337 | |
| 338 | /** |
| 339 | * CF17.1: Fetch the server's public HMAC key and persist in OPTION_SECRET. |
| 340 | * |
| 341 | * Idempotent: if OPTION_SECRET is already set, returns true immediately. |
| 342 | * If the network call fails, returns false (caller treats as transient). |
| 343 | * |
| 344 | * @return bool true if a secret is now available (either pre-existing or freshly fetched). |
| 345 | */ |
| 346 | public static function ensureServerSecret(): bool |
| 347 | { |
| 348 | // IMPORTANT: Do NOT short-circuit on LicenseManager::OPTION_SECRET |
| 349 | // (which holds the Freemius per-site license secret). That value is |
| 350 | // the WRONG secret for HMAC signing. Use a separate option key. |
| 351 | if (EncryptedOption::get(self::OPTION_SERVER_SECRET, '') !== '') { |
| 352 | return true; |
| 353 | } |
| 354 | |
| 355 | // Also sanity-check: if the legacy option DOES contain a non-empty |
| 356 | // value that came from /public-key, migrate it forward (defensive). |
| 357 | $legacy = (string) get_option(LicenseManager::OPTION_SECRET, ''); |
| 358 | if ($legacy !== '' && self::looksLikeServerSecret($legacy)) { |
| 359 | EncryptedOption::put(self::OPTION_SERVER_SECRET, $legacy); |
| 360 | return true; |
| 361 | } |
| 362 | |
| 363 | $serverUrl = (string) get_option('swapads_client_server_url', 'https://api.swapads.eu'); |
| 364 | $url = rtrim($serverUrl, '/') . '/wp-json/swapads-server/v1/public-key'; |
| 365 | |
| 366 | $response = wp_remote_get($url, ['timeout' => 10]); |
| 367 | if (!is_array($response)) { |
| 368 | return false; |
| 369 | } |
| 370 | $code = (int) wp_remote_retrieve_response_code($response); |
| 371 | if ($code !== 200) { |
| 372 | return false; |
| 373 | } |
| 374 | $body = (string) wp_remote_retrieve_body($response); |
| 375 | $data = json_decode($body, true); |
| 376 | if (!is_array($data) || empty($data['secret']) || !is_string($data['secret'])) { |
| 377 | return false; |
| 378 | } |
| 379 | EncryptedOption::put(self::OPTION_SERVER_SECRET, (string) $data['secret']); |
| 380 | return true; |
| 381 | } |
| 382 | |
| 383 | /** |
| 384 | * Heuristic: the server HMAC secret is a 64-char hex string |
| 385 | * (sha256). The Freemius per-site license secret is also a 64-char |
| 386 | * hex string, so we cannot reliably distinguish them by format alone. |
| 387 | * Use this only when the legacy option is empty. |
| 388 | */ |
| 389 | private static function looksLikeServerSecret(string $candidate): bool |
| 390 | { |
| 391 | return (bool) preg_match('/^[a-f0-9]{64}$/', $candidate); |
| 392 | } |
| 393 | |
| 394 | } |