1<?php
2/**
3 * Copyright © 2019-2026 Rhubarb Tech Inc. All Rights Reserved.
4 *
5 * The Object Cache Pro Software and its related materials are property and confidential
6 * information of Rhubarb Tech Inc. Any reproduction, use, distribution, or exploitation
7 * of the Object Cache Pro Software and its related materials, in whole or in part,
8 * is strictly forbidden unless prior permission is obtained from Rhubarb Tech Inc.
9 *
10 * In addition, any reproduction, use, distribution, or exploitation of the Object Cache Pro
11 * Software and its related materials, in whole or in part, is subject to the End-User License
12 * Agreement accessible in the included `LICENSE` file, or at: https://objectcache.pro/eula
13 */
14
15declare(strict_types=1);
16
17namespace RedisCachePro;
18
19use WP_Error;
20
21class License
22{
23 /**
24 * The license is valid.
25 *
26 * @var string
27 */
28 const Valid = 'valid';
29
30 /**
31 * The license was canceled.
32 *
33 * @var string
34 */
35 const Canceled = 'canceled';
36
37 /**
38 * The license is unpaid.
39 *
40 * @var string
41 */
42 const Unpaid = 'unpaid';
43
44 /**
45 * The license is invalid.
46 *
47 * @var string
48 */
49 const Invalid = 'invalid';
50
51 /**
52 * The license was deauthorized.
53 *
54 * @var string
55 */
56 const Deauthorized = 'deauthorized';
57
58 /**
59 * The list of stabilities.
60 *
61 * @var array<string, string>
62 */
63 const Stabilities = [
64 'stable' => 'Stable',
65 'rc' => 'Release Candidate',
66 'beta' => 'Beta',
67 'alpha' => 'Alpha',
68 'dev' => 'Development',
69 ];
70
71 /**
72 * The license plan.
73 *
74 * @var ?string
75 */
76 protected $plan;
77
78 /**
79 * The license state.
80 *
81 * @var ?string
82 */
83 protected $state;
84
85 /**
86 * The license token.
87 *
88 * @var ?string
89 */
90 protected $token;
91
92 /**
93 * The license organization.
94 *
95 * @var ?object
96 */
97 protected $organization;
98
99 /**
100 * The minimum accessible stability.
101 *
102 * @var ?string
103 */
104 protected $stability;
105
106 /**
107 * The last time the license was checked.
108 *
109 * @var int
110 */
111 protected $last_check;
112
113 /**
114 * The last time the license was verified.
115 *
116 * @var ?int
117 */
118 protected $valid_as_of;
119
120 /**
121 * The last error associated with the license.
122 *
123 * @var \WP_Error|null
124 */
125 protected $_error; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
126
127 /**
128 * The license token.
129 *
130 * @return ?string
131 */
132 public function token()
133 {
134 return $this->token;
135 }
136
137 /**
138 * The license state.
139 *
140 * @return ?string
141 */
142 public function state()
143 {
144 return $this->state;
145 }
146
147 /**
148 * The minimum accessible stabilities.
149 *
150 * @return array<string, string>
151 */
152 public function accessibleStabilities()
153 {
154 $stabilities = array_reverse(self::Stabilities);
155
156 foreach ($stabilities as $stability => $label) {
157 if ($stability === $this->stability) {
158 break;
159 }
160
161 unset($stabilities[$stability]);
162 }
163
164 return $stabilities;
165 }
166
167 /**
168 * Whether the license is valid.
169 *
170 * @return bool
171 */
172 public function isValid()
173 {
174 return $this->state === self::Valid;
175 }
176
177 /**
178 * Whether the license was canceled.
179 *
180 * @return bool
181 */
182 public function isCanceled()
183 {
184 return $this->state === self::Canceled;
185 }
186
187 /**
188 * Whether the license is unpaid.
189 *
190 * @return bool
191 */
192 public function isUnpaid()
193 {
194 return $this->state === self::Unpaid;
195 }
196
197 /**
198 * Whether the license is invalid.
199 *
200 * @return bool
201 */
202 public function isInvalid()
203 {
204 return $this->state === self::Invalid;
205 }
206
207 /**
208 * Whether the license was deauthorized.
209 *
210 * @return bool
211 */
212 public function isDeauthorized()
213 {
214 return $this->state === self::Deauthorized;
215 }
216
217 /**
218 * Load the plugin's license from the database.
219 *
220 * @return self|void
221 */
222 public static function load()
223 {
224 $license = get_site_option('objectcache_license');
225
226 // migrate old licenses gracefully
227 if ($license === false) {
228 $license = get_site_option('rediscache_license');
229
230 if ($license !== false) {
231 delete_site_option('rediscache_license');
232 update_site_option('objectcache_license', $license);
233 }
234 }
235
236 if (
237 is_object($license) &&
238 property_exists($license, 'token') &&
239 property_exists($license, 'state') &&
240 property_exists($license, 'last_check')
241 ) {
242 return static::fromObject($license);
243 }
244 }
245
246 /**
247 * Transform the license into a generic object.
248 *
249 * @return \stdClass
250 */
251 protected function toObject()
252 {
253 return (object) [
254 'plan' => $this->plan,
255 'state' => $this->state,
256 'token' => $this->token,
257 'organization' => $this->organization,
258 'stability' => $this->stability,
259 'last_check' => $this->last_check,
260 'valid_as_of' => $this->valid_as_of,
261 ];
262 }
263
264 /**
265 * Instantiate a new license from the given generic object.
266 *
267 * @param object $object
268 * @return self
269 */
270 public static function fromObject($object)
271 {
272 $license = new self;
273
274 foreach (get_object_vars($object) as $key => $value) {
275 property_exists($license, $key) && $license->{$key} = $value;
276 }
277
278 return $license;
279 }
280
281 /**
282 * Instantiate a new license from the given response object.
283 *
284 * @param object $response
285 * @return self
286 */
287 public static function fromResponse($response)
288 {
289 $license = static::fromObject($response);
290 $license->last_check = time();
291
292 if ($license->isValid()) {
293 $license->valid_as_of = time();
294 }
295
296 if (is_null($license->state)) {
297 $license->state = self::Invalid;
298 }
299
300 return $license->save();
301 }
302
303 /**
304 * Instantiate a new license from the given response object.
305 *
306 * @param WP_Error $error
307 * @return self
308 */
309 public static function fromError(WP_Error $error)
310 {
311 $license = new self;
312
313 foreach ((array) $error->get_error_data() as $key => $value) {
314 property_exists($license, $key) && $license->{$key} = $value;
315 }
316
317 return $license->checkFailed($error);
318 }
319
320 /**
321 * Persist the license as a network option.
322 *
323 * @return self
324 */
325 public function save()
326 {
327 update_site_option('objectcache_license', $this->toObject());
328
329 return $this;
330 }
331
332 /**
333 * Deauthorize the license.
334 *
335 * @return self
336 */
337 public function deauthorize()
338 {
339 $this->valid_as_of = null;
340 $this->state = self::Deauthorized;
341
342 return $this->save();
343 }
344
345 /**
346 * Bump the `last_check` timestamp on the license.
347 *
348 * @param \WP_Error $error
349 * @return self
350 */
351 public function checkFailed(WP_Error $error)
352 {
353 $this->_error = $error;
354 $this->last_check = time();
355
356 log('warning', $error->get_error_message());
357
358 return $this->save();
359 }
360
361 /**
362 * Whether it's been given minutes since the last check.
363 *
364 * @param int $minutes
365 * @return bool
366 */
367 public function minutesSinceLastCheck(int $minutes)
368 {
369 if (! $this->last_check) {
370 delete_site_option('rediscache_license_last_check');
371
372 return true;
373 }
374
375 $validUntil = $this->last_check + ($minutes * MINUTE_IN_SECONDS);
376
377 return $validUntil < time();
378 }
379
380 /**
381 * Whether it's been given hours since the last check.
382 *
383 * @param int $hours
384 * @return bool
385 */
386 public function hoursSinceLastCheck(int $hours)
387 {
388 return $this->minutesSinceLastCheck($hours * 60);
389 }
390
391 /**
392 * Whether it's been given hours since the license was successfully verified.
393 *
394 * @param int $hours
395 * @return bool
396 */
397 public function hoursSinceVerification(int $hours)
398 {
399 if (! $this->valid_as_of) {
400 return true;
401 }
402
403 $validUntil = $this->valid_as_of + ($hours * HOUR_IN_SECONDS);
404
405 return $validUntil < time();
406 }
407
408 /**
409 * Whether the license needs to be verified again.
410 *
411 * @see \RedisCachePro\Plugin\Licensing::license()
412 * @return bool
413 */
414 public function needsReverification()
415 {
416 if ($this->isValid() && $this->hoursSinceLastCheck($this->hostingLicense() ? 24 : 6)) {
417 return true;
418 }
419
420 if (! $this->isValid() && $this->minutesSinceLastCheck(20)) {
421 return true;
422 }
423
424 return false;
425 }
426
427 /**
428 * Whether the license belongs to an Lx partner.
429 *
430 * @return bool
431 */
432 public function hostingLicense()
433 {
434 return (bool) preg_match('/^L\d /', (string) $this->plan);
435 }
436
437 /**
438 * Returns the error meta data.
439 *
440 * @return array<string, mixed>
441 */
442 public function errorData()
443 {
444 if (! isset($this->_error)) {
445 return [];
446 }
447
448 return array_merge([
449 'code' => $this->_error->get_error_code(),
450 ], array_diff_key(
451 $this->_error->get_error_data() ?? [],
452 ['token' => null]
453 ));
454 }
455}
456