Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.02% covered (danger)
49.02%
50 / 102
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
LicenseManager
49.02% covered (danger)
49.02%
50 / 102
40.00% covered (danger)
40.00%
6 / 15
536.99
0.00% covered (danger)
0.00%
0 / 1
 isLicensed
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
11.30
 key
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
7.73
 secret
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 siteId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 domain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 status
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
77.27
 freemiusPlanId
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 store
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 forget
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 activateViaSdk
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 isSdkLoaded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 freemiusSiteSecret
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 freemiusSiteId
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
22.94
 sdkHasSite
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 visitorIdHash
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
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
18declare(strict_types=1);
19
20namespace SwapAds\Client\License;
21
22/**
23 * Class LicenseManager.
24 *
25 * @since 1.0.0
26 */
27final 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}