| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 31.45% covered (danger) | 31.45% | 39 / 124 | 30.77% covered (danger) | 30.77% | 4 / 13 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
FreemiusAutoActivator | 31.45% covered (danger) | 31.45% | 39 / 124 | 30.77% covered (danger) | 30.77% | 4 / 13 | 727.57 | 0.00% covered (danger) | 0.00% | 0 / 1
register | 0.00% covered (danger) | 0.00% | 0 / 3 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
scheduleResyncCron | 0.00% covered (danger) | 0.00% | 0 / 2 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
shouldAttempt | 25.00% covered (danger) | 25.00% | 2 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 10.75 |
sdkHasLicense | 18.18% covered (danger) | 18.18% | 2 / 11 | 0.00% covered (danger) | 0.00% | 0 / 1 | 43.05 |
sdkHasSite | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
maybeAutoActivate | 0.00% covered (danger) | 0.00% | 0 / 3 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
forceResync | 0.00% covered (danger) | 0.00% | 0 / 3 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
runActivation | 16.13% covered (danger) | 16.13% | 10 / 62 | 0.00% covered (danger) | 0.00% | 0 / 1 | 69.00 |
getStatus | 40.00% covered (danger) | 40.00% | 2 / 5 | 0.00% covered (danger) | 0.00% | 0 / 1 | 4.94 |
getLastResult | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
retryNow | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
ensureServerSecret | 80.00% covered (warning) | 80.00% | 16 / 20 | 0.00% covered (danger) | 0.00% | 0 / 1 | 9.65 |
looksLikeServerSecret | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 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 | }