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
33declare(strict_types=1);
34
35namespace SwapAds\Client\License;
36
37use SwapAds\Client\Security\EncryptedOption;
38
39use SwapAds\Client\Api\RestClient;
40
41/**
42 * Class FreemiusAutoActivator.
43 *
44 * @since 1.2.0
45 */
46final 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    }