| 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 |
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 | }