PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / trunk
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More vtrunk
6.35 6.34 6.33.1 6.33 6.32.1 6.32 6.31 6.25 6.25.1 6.26 6.26.1 6.27 6.28 6.29 6.3 6.3.1 6.3.2 6.30 6.4 6.4.1 6.4.2 6.5 6.5.1 6.5.2 6.5.3 All 141 releases
← All changes | classes/models/FrmFormApi.php +336 -35 6.5 → trunk View file →
@@ -4,21 +4,54 @@
4 4 }
5 5
6 6 class FrmFormApi {
7 7
8 + /**
9 + * @var string
10 + */
8 11 protected $license = '';
12 +
13 + /**
14 + * @var string
15 + */
9 16 protected $cache_key = '';
17 +
18 + /**
19 + * @var string
20 + */
10 21 protected $cache_timeout = '+6 hours';
11 22
12 23 /**
13 24 * The number of days an add-on is new.
14 25 *
15 - * @var int $new_days
26 + * @var int
16 27 */
17 28 protected $new_days = 90;
18 29
19 30 /**
31 + * If true, calls to get_api_info will bypass cache.
32 + * This is set true by calling force_api_request.
33 + *
34 + * @var bool
35 + */
36 + protected $force = false;
37 +
38 + /**
39 + * The most recent decode of each cache key's payload.
40 + *
41 + * Each entry is array( 'raw' => string, 'decoded' => mixed ). The raw JSON is kept so a reuse
42 + * can be checked against what is stored right now, rather than assumed to still be current.
43 + *
44 + * @var array<string,array>
45 + */
46 + private static $decoded_cache = array();
47 +
48 + /**
20 49 * @since 3.06
50 + *
51 + * @param string|null $license The license key.
52 + *
53 + * @return void
21 54 */
22 55 public function __construct( $license = null ) {
23 56 $this->set_license( $license );
24 57 $this->set_cache_key();
@@ -26,14 +59,17 @@
26 59
27 60 /**
28 61 * @since 3.06
29 62 *
63 + * @param string|null $license The license key.
64 + *
30 65 * @return void
31 66 */
32 67 private function set_license( $license ) {
33 68 if ( $license === null ) {
34 69 $edd_update = $this->get_pro_updater();
35 - if ( ! empty( $edd_update ) ) {
70 +
71 + if ( $edd_update ) {
36 72 $license = $edd_update->license;
37 73 }
38 74 }
39 75 $this->license = $license;
@@ -40,8 +76,9 @@
40 76 }
41 77
42 78 /**
43 79 * @since 3.06
80 + *
44 81 * @return string
45 82 */
46 83 public function get_license() {
47 84 return $this->license;
@@ -52,13 +89,14 @@
52 89 *
53 90 * @return void
54 91 */
55 92 protected function set_cache_key() {
56 - $this->cache_key = 'frm_addons_l' . ( empty( $this->license ) ? '' : md5( $this->license ) );
93 + $this->cache_key = 'frm_addons_l' . ( $this->license ? md5( $this->license ) : '' );
57 94 }
58 95
59 96 /**
60 97 * @since 3.06
98 + *
61 99 * @return string
62 100 */
63 101 public function get_cache_key() {
64 102 return $this->cache_key;
@@ -64,24 +102,52 @@
64 102 return $this->cache_key;
65 103 }
66 104
67 105 /**
106 + * Flag the force property as true, so the next API request bypasses cache.
107 + * This is used to pull API data for change logs, which are excluded from the cached data.
108 + *
109 + * @since 6.28
110 + *
111 + * @return void
112 + */
113 + public function force_api_request() {
114 + $this->force = true;
115 + }
116 +
117 + /**
68 118 * @since 3.06
119 + *
69 120 * @return array
70 121 */
71 122 public function get_api_info() {
72 123 $url = $this->api_url();
73 - if ( ! empty( $this->license ) ) {
124 +
125 + if ( $this->license ) {
74 126 $url .= '?l=' . urlencode( base64_encode( $this->license ) );
75 127 }
76 128
77 - $addons = $this->get_cached();
129 + if ( $this->force ) {
130 + $addons = false;
131 + $this->force = false;
132 + } else {
133 + $addons = $this->get_cached();
134 + }
135 +
78 136 if ( is_array( $addons ) ) {
79 137 return $addons;
80 138 }
81 139
140 + if ( $this->is_running() ) {
141 + // If there's no saved cache, we'll need to wait for the current request to finish.
142 + return array();
143 + }
144 +
145 + $this->set_running();
146 +
82 147 // We need to know the version number to allow different downloads.
83 148 $agent = 'formidable/' . FrmAppHelper::plugin_version();
149 +
84 150 if ( class_exists( 'FrmProDb' ) ) {
85 151 $agent = 'formidable-pro/' . FrmProDb::$plug_version;
86 152 }
87 153
@@ -100,8 +166,10 @@
100 166 if ( ! is_array( $addons ) ) {
101 167 $addons = array();
102 168 }
103 169
170 + $addons['response_code'] = wp_remote_retrieve_response_code( $response );
171 +
104 172 foreach ( $addons as $k => $addon ) {
105 173 if ( ! is_array( $addon ) ) {
106 174 continue;
107 175 }
@@ -107,9 +175,10 @@
107 175 }
108 176
109 177 if ( isset( $addon['categories'] ) ) {
110 178 $cats = array_intersect( $this->skip_categories(), $addon['categories'] );
111 - if ( ! empty( $cats ) ) {
179 +
180 + if ( $cats ) {
112 181 unset( $addons[ $k ] );
113 182 continue;
114 183 }
115 184 }
@@ -119,18 +188,86 @@
119 188 }
120 189 }
121 190
122 191 $this->set_cached( $addons );
192 + $this->done_running();
123 193
124 194 return $addons;
125 195 }
126 196
127 197 /**
198 + * Prevent multiple requests from running at the same time.
199 + *
200 + * @since 6.8.3
201 + *
202 + * @return bool
203 + */
204 + protected function is_running() {
205 + if ( $this->run_as_multisite() ) {
206 + return get_site_transient( $this->transient_key() );
207 + }
208 + return get_transient( $this->transient_key() );
209 + }
210 +
211 + /**
212 + * @since 6.8.3
213 + *
214 + * @return void
215 + */
216 + protected function set_running() {
217 + $expires = 2 * MINUTE_IN_SECONDS;
218 +
219 + if ( $this->run_as_multisite() ) {
220 + set_site_transient( $this->transient_key(), true, $expires );
221 + return;
222 + }
223 +
224 + set_transient( $this->transient_key(), true, $expires );
225 + }
226 +
227 + /**
228 + * @since 6.8.3
229 + *
230 + * @return void
231 + */
232 + protected function done_running() {
233 + if ( $this->run_as_multisite() ) {
234 + delete_site_transient( $this->transient_key() );
235 + }
236 + delete_transient( $this->transient_key() );
237 + }
238 +
239 + /**
240 + * Only allow one site in the network to make the api request at a time.
241 + * If there is a license for the request, run individually.
242 + *
243 + * @since 6.8.3
244 + *
245 + * @return bool
246 + */
247 + protected function run_as_multisite() {
248 + return is_multisite() && ! $this->license;
249 + }
250 +
251 + /**
252 + * @since 6.8.3
253 + *
254 + * @return string
255 + */
256 + protected function transient_key() {
257 + return strtolower( static::class ) . '_request_lock';
258 + }
259 +
260 + /**
128 261 * @since 3.06
129 262 *
130 263 * @return string
131 264 */
132 265 protected function api_url() {
266 + if ( ! $this->license ) {
267 + // Direct traffic to Cloudflare worker when there is no license.
268 + return 'https://plapi.formidableforms.com/list/';
269 + }
133 270 return 'https://formidableforms.com/wp-json/s11edd/v1/updates/';
134 271 }
135 272
136 273 /**
@@ -144,22 +281,24 @@
144 281
145 282 /**
146 283 * @since 3.06
147 284 *
148 - * @param object $license_plugin The FrmAddon object
149 - * @param array $addons
285 + * @param object $license_plugin The FrmAddon object.
286 + * @param array $addons
150 287 *
151 288 * @return array
152 289 */
153 290 public function get_addon_for_license( $license_plugin, $addons = array() ) {
154 - if ( empty( $addons ) ) {
291 + if ( ! $addons ) {
155 292 $addons = $this->get_api_info();
156 293 }
294 +
157 295 $download_id = $license_plugin->download_id;
158 296 $plugin = array();
159 - if ( empty( $download_id ) && ! empty( $addons ) ) {
297 +
298 + if ( ! $download_id && $addons ) {
160 299 foreach ( $addons as $addon ) {
161 - if ( strtolower( $license_plugin->plugin_name ) == strtolower( $addon['title'] ) ) {
300 + if ( is_array( $addon ) && ! empty( $addon['title'] ) && 0 === strcasecmp( $license_plugin->plugin_name, $addon['title'] ) ) {
162 301 return $addon;
163 302 }
164 303 }
165 304 } elseif ( isset( $addons[ $download_id ] ) ) {
@@ -170,8 +309,10 @@
170 309 }
171 310
172 311 /**
173 312 * @since 3.06
313 + *
314 + * @return false|object
174 315 */
175 316 public function get_pro_updater() {
176 317 if ( FrmAppHelper::pro_is_installed() && is_callable( 'FrmProAppHelper::get_updater' ) ) {
177 318 $updater = FrmProAppHelper::get_updater();
@@ -184,30 +325,86 @@
184 325 }
185 326
186 327 /**
187 328 * @since 3.06
188 - * @return array
329 + *
330 + * @return array|bool
189 331 */
190 332 protected function get_cached() {
191 - $cache = get_option( $this->cache_key );
333 + $cache = $this->get_cached_option();
192 334
193 - FrmAppHelper::filter_gmt_offset();
335 + if ( ! $cache ) {
336 + return false;
337 + }
194 338
195 - if ( empty( $cache ) || empty( $cache['timeout'] ) || current_time( 'timestamp' ) > $cache['timeout'] ) {
196 - return false; // Cache is expired
339 + $is_expired = empty( $cache['timeout'] ) || time() > $cache['timeout'];
340 +
341 + if ( ! $is_expired && isset( $cache['version'] ) && $cache['version'] !== FrmAppHelper::plugin_version() ) {
342 + $is_expired = true;
197 343 }
198 344
199 - $version = FrmAppHelper::plugin_version();
200 - $for_current = isset( $cache['version'] ) && $cache['version'] == $version;
201 - if ( ! $for_current ) {
202 - // Force a new check.
345 + // Avoid old cached data, unless we're currently trying to query for new data.
346 + // The call to $this->is_running likely triggers a database query, so only call if if we're expired.
347 + // (Rather than the other way around, which is less efficient).
348 + if ( $is_expired && ! $this->is_running() ) {
203 349 return false;
204 350 }
205 351
206 - return json_decode( $cache['value'], true );
352 + return $this->decode_cached( $cache['value'] );
207 353 }
208 354
209 355 /**
356 + * Decode a cached API payload, reusing the previous decode of an unchanged payload.
357 + *
358 + * The payload runs to tens of kilobytes of JSON, and decoding it costs far more than reading
359 + * the option it came from. That matters because a single form builder page decodes it once per
360 + * upsell it renders. The reuse is keyed on the raw JSON rather than on the cache key, so
361 + * anything that changes what is stored -- a new license, a cleared cache, a fresh API response,
362 + * a test replacing the option -- is picked up on the very next read instead of being masked.
363 + *
364 + * @since 6.35
365 + *
366 + * @param string $value The cached JSON.
367 + *
368 + * @return mixed
369 + */
370 + private function decode_cached( $value ) {
371 + $previous = self::$decoded_cache[ $this->cache_key ] ?? false;
372 +
373 + if ( $previous && $previous['raw'] === $value ) {
374 + return $previous['decoded'];
375 + }
376 +
377 + $decoded = json_decode( $value, true );
378 +
379 + self::$decoded_cache[ $this->cache_key ] = array(
380 + 'raw' => $value,
381 + 'decoded' => $decoded,
382 + );
383 +
384 + return $decoded;
385 + }
386 +
387 + /**
388 + * Get the cache for the network if multisite.
389 + *
390 + * @since 6.8.3
391 + *
392 + * @return mixed
393 + */
394 + protected function get_cached_option() {
395 + if ( is_multisite() ) {
396 + $cached = get_site_option( $this->cache_key );
397 +
398 + if ( $cached ) {
399 + return $cached;
400 + }
401 + }
402 +
403 + return get_option( $this->cache_key );
404 + }
405 +
406 + /**
210 407 * @since 3.06
211 408 *
212 409 * @param array $addons
213 410 *
@@ -213,55 +410,158 @@
213 410 *
214 411 * @return void
215 412 */
216 413 protected function set_cached( $addons ) {
217 - FrmAppHelper::filter_gmt_offset();
414 + $addons = $this->reduce_addon_data_before_caching( $addons );
218 415
219 416 $data = array(
220 - 'timeout' => strtotime( $this->cache_timeout, current_time( 'timestamp' ) ),
221 - 'value' => json_encode( $addons ),
417 + 'timeout' => strtotime( $this->get_cache_timeout( $addons ), time() ),
418 + 'value' => wp_json_encode( $addons ),
222 419 'version' => FrmAppHelper::plugin_version(),
223 420 );
224 421
225 - update_option( $this->cache_key, $data, 'no' );
422 + if ( is_multisite() ) {
423 + update_site_option( $this->cache_key, $data );
424 + } else {
425 + // Autoload the license cache because it gets called everywhere.
426 + $autoload = str_starts_with( $this->cache_key, 'frm_addons_l' );
427 + update_option( $this->cache_key, $data, $autoload );
428 + }
226 429 }
227 430
228 431 /**
432 + * Remove certain add-on API data that we don't need to cache.
433 + * This is to help keep the option data (which is auto-loaded) small.
434 + *
435 + * @since 6.28
436 + *
437 + * @param array $addons
438 + *
439 + * @return array
440 + */
441 + private function reduce_addon_data_before_caching( $addons ) {
442 + if ( is_subclass_of( $this, 'FrmFormApi' ) ) {
443 + // We only want to modify FrmFormApi. Leave the other APIs alone for now.
444 + return $addons;
445 + }
446 +
447 + $reduced_addons = array();
448 +
449 + foreach ( $addons as $key => $addon ) {
450 + if ( ! is_array( $addon ) ) {
451 + $reduced_addons[ $key ] = $addon;
452 + continue;
453 + }
454 +
455 + if ( ! $this->should_include_addon_in_cached_data( $addon ) ) {
456 + continue;
457 + }
458 +
459 + if ( isset( $addon['changelog'] ) ) {
460 + unset( $addon['changelog'], $addon['banners'] );
461 + }
462 +
463 + $reduced_addons[ $key ] = $addon;
464 + }
465 +
466 + return $reduced_addons;
467 + }
468 +
469 + /**
470 + * @since 6.28
471 + *
472 + * @param array $addon
473 + *
474 + * @return bool True if the add-on should be included in cached data.
475 + */
476 + private function should_include_addon_in_cached_data( $addon ) {
477 + if ( isset( $addon['version'] ) && '' === $addon['version'] ) {
478 + // If version is set but blank, the plugin is not actually live.
479 + return false;
480 + }
481 +
482 + if ( isset( $addon['categories'] ) ) {
483 + if ( isset( $addon['slug'] ) && 'views' === $addon['slug'] ) {
484 + // Legacy views has no categories set, but we should still
485 + // Include it in cache since it is a valid add-on.
486 + return true;
487 + }
488 +
489 + $categories_are_empty = ! $addon['categories'] || $addon['categories'] === array( 'Strategy11' );
490 +
491 + if ( $categories_are_empty ) {
492 + return false;
493 + }
494 + }
495 +
496 + return true;
497 + }
498 +
499 + /**
500 + * If the last check was a a rate limit, we'll need to check again sooner.
501 + *
502 + * @since 6.8.3
503 + *
504 + * @param array $addons
505 + *
506 + * @return string
507 + */
508 + protected function get_cache_timeout( $addons ) {
509 + if ( isset( $addons['response_code'] ) && 429 === $addons['response_code'] ) {
510 + return '+5 minutes';
511 + }
512 + return $this->cache_timeout;
513 + }
514 +
515 + /**
229 516 * @since 3.06
230 517 *
231 518 * @return void
232 519 */
233 520 public function reset_cached() {
234 - delete_option( $this->cache_key );
521 + if ( is_multisite() ) {
522 + delete_site_option( $this->cache_key );
523 + } else {
524 + delete_option( $this->cache_key );
525 + }
526 + $this->done_running();
235 527 }
236 528
237 529 /**
238 530 * @since 3.06
531 + *
239 532 * @return array
240 533 */
241 534 public function error_for_license() {
242 - $errors = array();
243 - if ( ! empty( $this->license ) ) {
244 - $errors = $this->get_error_from_response();
245 - }
246 -
247 - return $errors;
535 + return $this->license ? $this->get_error_from_response() : array();
248 536 }
249 537
250 538 /**
251 539 * @since 3.06
540 + *
541 + * @param array $addons
542 + *
252 543 * @return array
253 544 */
254 545 public function get_error_from_response( $addons = array() ) {
255 - if ( empty( $addons ) ) {
546 + if ( ! $addons ) {
256 547 $addons = $this->get_api_info();
257 548 }
549 +
258 550 $errors = array();
259 - if ( isset( $addons['error'] ) ) {
551 +
552 + if ( ! isset( $addons['error'] ) ) {
553 + return $errors;
554 + }
555 +
556 + if ( is_string( $addons['error'] ) ) {
557 + $errors[] = $addons['error'];
558 + } elseif ( ! empty( $addons['error']['message'] ) ) {
260 559 $errors[] = $addons['error']['message'];
261 - do_action( 'frm_license_error', $addons['error'] );
262 560 }
263 561
562 + do_action( 'frm_license_error', $addons['error'] );
563 +
264 564 return $errors;
265 565 }
266 566
267 567 /**
@@ -269,8 +569,9 @@
269 569 *
270 570 * @since 6.0
271 571 *
272 572 * @param array $addon
573 + *
273 574 * @return bool
274 575 */
275 576 protected function is_new( $addon ) {
276 577 return strtotime( $addon['released'] ) > strtotime( '-' . $this->new_days . ' days' );