PluginProbe ʕ •ᴥ•ʔ
Code Manager / 1.0.35
Code Manager v1.0.35
1.0.47 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.19 1.0.2 1.0.20 1.0.21 1.0.22 1.0.23 1.0.24 1.0.25 1.0.26 1.0.27 1.0.28 1.0.3 1.0.30 1.0.31 1.0.32 1.0.33 1.0.34 1.0.35 1.0.36 1.0.37 1.0.38 1.0.39 1.0.4 1.0.40 1.0.41 1.0.42 1.0.43 1.0.44 1.0.45 1.0.46 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9
code-manager / freemius / includes / class-fs-garbage-collector.php
code-manager / freemius / includes Last commit date
customizer 2 years ago debug 2 years ago entities 2 years ago managers 2 years ago sdk 2 years ago supplements 2 years ago class-freemius-abstract.php 2 years ago class-freemius.php 2 years ago class-fs-admin-notices.php 2 years ago class-fs-api.php 2 years ago class-fs-garbage-collector.php 2 years ago class-fs-lock.php 2 years ago class-fs-logger.php 2 years ago class-fs-options.php 2 years ago class-fs-plugin-updater.php 2 years ago class-fs-security.php 2 years ago class-fs-storage.php 2 years ago class-fs-user-lock.php 2 years ago fs-core-functions.php 2 years ago fs-essential-functions.php 2 years ago fs-html-escaping-functions.php 2 years ago fs-plugin-info-dialog.php 2 years ago index.php 2 years ago l10n.php 2 years ago
class-fs-garbage-collector.php
439 lines
1 <?php
2 /**
3 * @package Freemius
4 * @copyright Copyright (c) 2015, Freemius, Inc.
5 * @license https://www.gnu.org/licenses/gpl-3.0.html GNU General Public License Version 3
6 * @since 2.6.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 interface FS_I_Garbage_Collector {
14 function clean();
15 }
16
17 class FS_Product_Garbage_Collector implements FS_I_Garbage_Collector {
18 /**
19 * @var FS_Options
20 */
21 private $_accounts;
22
23 /**
24 * @var string[]
25 */
26 private $_options_names;
27
28 /**
29 * @var string
30 */
31 private $_type;
32
33 /**
34 * @var string
35 */
36 private $_plural_type;
37
38 /**
39 * @var array<string, int> Map of product slugs to their last load timestamp, only for products that are not active.
40 */
41 private $_gc_timestamp;
42
43 /**
44 * @var array<string, array<string, mixed>> Map of product slugs to their data, as stored by the primary storage of `Freemius` class.
45 */
46 private $_storage_data;
47
48 function __construct( FS_Options $_accounts, $option_names, $type ) {
49 $this->_accounts = $_accounts;
50 $this->_options_names = $option_names;
51 $this->_type = $type;
52 $this->_plural_type = ( $type . 's' );
53 }
54
55 function clean() {
56 $this->_gc_timestamp = $this->_accounts->get_option( 'gc_timestamp', array() );
57 $this->_storage_data = $this->_accounts->get_option( $this->_type . '_data', array() );
58
59 $options = $this->load_options();
60 $has_updated_option = false;
61
62 $filtered_products = $this->get_filtered_products();
63 $products_to_clean = $filtered_products['products_to_clean'];
64 $active_products_by_id_map = $filtered_products['active_products_by_id_map'];
65
66 foreach( $products_to_clean as $product ) {
67 $slug = $product->slug;
68
69 // Clear the product's data.
70 foreach( $options as $option_name => $option ) {
71 $updated = false;
72
73 /**
74 * We expect to deal with only array like options here.
75 * @todo - Refactor this to create dedicated GC classes for every option, then we can make the code mode predictable.
76 * For example, depending on data integrity of `plugins` we can still miss something entirely in the `plugin_data` or vice-versa.
77 * A better algorithm is to iterate over all options individually in separate classes and check against primary storage to see if those can be garbage collected.
78 * But given the chance of data integrity issue is very low, we let this run for now and gather feedback.
79 */
80 if ( ! is_array( $option ) ) {
81 continue;
82 }
83
84 if ( array_key_exists( $slug, $option ) ) {
85 unset( $option[ $slug ] );
86 $updated = true;
87 } else if ( array_key_exists( "{$slug}:{$this->_type}", $option ) ) { /* admin_notices */
88 unset( $option[ "{$slug}:{$this->_type}" ] );
89 $updated = true;
90 } else if ( isset( $product->id ) && array_key_exists( $product->id, $option ) ) { /* all_licenses, add-ons, and id_slug_type_path_map */
91 $is_inactive_by_id = ! isset( $active_products_by_id_map[ $product->id ] );
92 $is_inactive_by_slug = (
93 'id_slug_type_path_map' === $option_name &&
94 (
95 ! isset( $option[ $product->id ]['slug'] ) ||
96 $slug === $option[ $product->id ]['slug']
97 )
98 );
99
100 if ( $is_inactive_by_id || $is_inactive_by_slug ) {
101 unset( $option[ $product->id ] );
102 $updated = true;
103 }
104 } else if ( /* file_slug_map */
105 isset( $product->file ) &&
106 array_key_exists( $product->file, $option ) &&
107 $slug === $option[ $product->file ]
108 ) {
109 unset( $option[ $product->file ] );
110 $updated = true;
111 }
112
113 if ( $updated ) {
114 $this->_accounts->set_option( $option_name, $option );
115
116 $options[ $option_name ] = $option;
117
118 $has_updated_option = true;
119 }
120 }
121
122 // Clear the product's data from the primary storage.
123 if ( isset( $this->_storage_data[ $slug ] ) ) {
124 unset( $this->_storage_data[ $slug ] );
125 $has_updated_option = true;
126 }
127
128 // Clear from GC timestamp.
129 // @todo - This perhaps needs a separate garbage collector for all expired products. But the chance of left-over is very slim.
130 if ( isset( $this->_gc_timestamp[ $slug ] ) ) {
131 unset( $this->_gc_timestamp[ $slug ] );
132 $has_updated_option = true;
133 }
134 }
135
136 $this->_accounts->set_option( 'gc_timestamp', $this->_gc_timestamp );
137 $this->_accounts->set_option( $this->_type . '_data', $this->_storage_data );
138
139 return $has_updated_option;
140 }
141
142 private function get_all_option_names() {
143 return array_merge(
144 array(
145 'admin_notices',
146 'updates',
147 'all_licenses',
148 'addons',
149 'id_slug_type_path_map',
150 'file_slug_map',
151 ),
152 $this->_options_names
153 );
154 }
155
156 private function get_products() {
157 $products = $this->_accounts->get_option( $this->_plural_type, array() );
158
159 // Fill any missing product found in the primary storage.
160 // @todo - This wouldn't be needed if we use dedicated GC design for every options. The options themselves would provide such information.
161 foreach( $this->_storage_data as $slug => $product_data ) {
162 if ( ! isset( $products[ $slug ] ) ) {
163 $products[ $slug ] = (object) $product_data;
164 }
165
166 // This is needed to handle a scenario in which there are duplicate sets of data for the same product, but one of them needs to be removed.
167 $products[ $slug ] = clone $products[ $slug ];
168
169 // The reason for having the line above. This also handles a scenario in which the slug is either empty or not empty but incorrect.
170 $products[ $slug ]->slug = $slug;
171 }
172
173 $this->update_gc_timestamp( $products );
174
175 return $products;
176 }
177
178 private function get_filtered_products() {
179 $products_to_clean = array();
180 $active_products_by_id_map = array();
181
182 $products = $this->get_products();
183
184 foreach ( $products as $slug => $product_data ) {
185 if ( ! is_object( $product_data ) ) {
186 continue;
187 }
188
189 if ( $this->is_product_active( $slug ) ) {
190 $active_products_by_id_map[ $product_data->id ] = true;
191 continue;
192 }
193
194 $is_addon = ( ! empty( $product_data->parent_plugin_id ) );
195
196 if ( ! $is_addon ) {
197 $products_to_clean[] = $product_data;
198 } else {
199 /**
200 * If add-on, add to the beginning of the array so that add-ons are removed before their parent. This is to prevent an unexpected issue when an add-on exists but its parent was already removed.
201 */
202 array_unshift( $products_to_clean, $product_data );
203 }
204 }
205
206 return array(
207 'products_to_clean' => $products_to_clean,
208 'active_products_by_id_map' => $active_products_by_id_map,
209 );
210 }
211
212 /**
213 * @param string $slug
214 *
215 * @return bool
216 */
217 private function is_product_active( $slug ) {
218 $instances = Freemius::_get_all_instances();
219
220 foreach ( $instances as $instance ) {
221 if ( $instance->get_slug() === $slug ) {
222 return true;
223 }
224 }
225
226 $expiration_time = fs_get_optional_constant( 'WP_FS__GARBAGE_COLLECTOR_EXPIRATION_TIME_SECS', ( WP_FS__TIME_WEEK_IN_SEC * 4 ) );
227
228 if ( $this->get_last_load_timestamp( $slug ) > ( time() - $expiration_time ) ) {
229 // Last activation was within the last 4 weeks.
230 return true;
231 }
232
233 return false;
234 }
235
236 private function load_options() {
237 $options = array();
238 $option_names = $this->get_all_option_names();
239
240 foreach ( $option_names as $option_name ) {
241 $options[ $option_name ] = $this->_accounts->get_option( $option_name, array() );
242 }
243
244 return $options;
245 }
246
247 /**
248 * Updates the garbage collector timestamp, only if it was not already set by the product's primary storage.
249 *
250 * @param array $products
251 *
252 * @return void
253 */
254 private function update_gc_timestamp( $products ) {
255 foreach ($products as $slug => $product_data) {
256 if ( ! is_object( $product_data ) && ! is_array( $product_data ) ) {
257 continue;
258 }
259
260
261 // If the product is active, we don't need to update the gc_timestamp.
262 if ( isset( $this->_storage_data[ $slug ]['last_load_timestamp'] ) ) {
263 continue;
264 }
265
266 // First try to check if the product is present in the primary storage. If so update that.
267 if ( isset( $this->_storage_data[ $slug ] ) ) {
268 $this->_storage_data[ $slug ]['last_load_timestamp'] = time();
269 } else if ( ! isset( $this->_gc_timestamp[ $slug ] ) ) {
270 // If not, fallback to the gc_timestamp, but we don't want to update it more than once.
271 $this->_gc_timestamp[ $slug ] = time();
272 }
273 }
274 }
275
276 private function get_last_load_timestamp( $slug ) {
277 if ( isset( $this->_storage_data[ $slug ]['last_load_timestamp'] ) ) {
278 return $this->_storage_data[ $slug ]['last_load_timestamp'];
279 }
280
281 return isset( $this->_gc_timestamp[ $slug ] ) ?
282 $this->_gc_timestamp[ $slug ] :
283 // This should never happen, but if it does, let's assume the product is not expired.
284 time();
285 }
286 }
287
288 class FS_User_Garbage_Collector implements FS_I_Garbage_Collector {
289 private $_accounts;
290
291 private $_types;
292
293 function __construct( FS_Options $_accounts, array $types ) {
294 $this->_accounts = $_accounts;
295 $this->_types = $types;
296 }
297
298 function clean() {
299 $users = Freemius::get_all_users();
300
301 $user_has_install_map = $this->get_user_has_install_map();
302
303 if ( count( $users ) === count( $user_has_install_map ) ) {
304 return false;
305 }
306
307 $products_user_id_license_ids_map = $this->_accounts->get_option( 'user_id_license_ids_map', array() );
308
309 $has_updated_option = false;
310
311 foreach ( $users as $user_id => $user ) {
312 if ( ! isset( $user_has_install_map[ $user_id ] ) ) {
313 unset( $users[ $user_id ] );
314
315 foreach( $products_user_id_license_ids_map as $product_id => $user_id_license_ids_map ) {
316 unset( $user_id_license_ids_map[ $user_id ] );
317
318 if ( empty( $user_id_license_ids_map ) ) {
319 unset( $products_user_id_license_ids_map[ $product_id ] );
320 } else {
321 $products_user_id_license_ids_map[ $product_id ] = $user_id_license_ids_map;
322 }
323 }
324
325 $this->_accounts->set_option( 'users', $users );
326 $this->_accounts->set_option( 'user_id_license_ids_map', $products_user_id_license_ids_map );
327
328 $has_updated_option = true;
329 }
330 }
331
332 return $has_updated_option;
333 }
334
335 private function get_user_has_install_map() {
336 $user_has_install_map = array();
337
338 foreach ( $this->_types as $product_type ) {
339 $option_name = ( WP_FS__MODULE_TYPE_PLUGIN !== $product_type ) ?
340 "{$product_type}_sites" :
341 'sites';
342
343 $installs = $this->_accounts->get_option( $option_name, array() );
344
345 foreach ( $installs as $install ) {
346 $user_has_install_map[ $install->user_id ] = true;
347 }
348 }
349
350 return $user_has_install_map;
351 }
352 }
353
354 // Main entry-level class.
355 class FS_Garbage_Collector implements FS_I_Garbage_Collector {
356 /**
357 * @var FS_Garbage_Collector
358 * @since 2.6.0
359 */
360 private static $_instance;
361
362 /**
363 * @return FS_Garbage_Collector
364 */
365 static function instance() {
366 if ( ! isset( self::$_instance ) ) {
367 self::$_instance = new self();
368 }
369
370 return self::$_instance;
371 }
372
373 #endregion
374
375 private function __construct() {
376 }
377
378 function clean() {
379 $_accounts = FS_Options::instance( WP_FS__ACCOUNTS_OPTION_NAME, true );
380
381 $products_cleaners = $this->get_product_cleaners( $_accounts );
382
383 $has_cleaned = false;
384
385 foreach ( $products_cleaners as $products_cleaner ) {
386 if ( $products_cleaner->clean() ) {
387 $has_cleaned = true;
388 }
389 }
390
391 if ( $has_cleaned ) {
392 $user_cleaner = new FS_User_Garbage_Collector(
393 $_accounts,
394 array_keys( $products_cleaners )
395 );
396
397 $user_cleaner->clean();
398 }
399
400 // @todo - We need a garbage collector for `all_plugins` and `active_plugins` (and variants of themes).
401
402 // Always store regardless of whether there were cleaned products or not since during the process, the logic may set the last load timestamp of some products.
403 $_accounts->store();
404 }
405
406 /**
407 * @param FS_Options $_accounts
408 *
409 * @return FS_I_Garbage_Collector[]
410 */
411 private function get_product_cleaners( FS_Options $_accounts ) {
412 /**
413 * @var FS_I_Garbage_Collector[] $products_cleaners
414 */
415 $products_cleaners = array();
416
417 $products_cleaners[ WP_FS__MODULE_TYPE_PLUGIN ] = new FS_Product_Garbage_Collector(
418 $_accounts,
419 array(
420 'sites',
421 'plans',
422 'plugins',
423 ),
424 WP_FS__MODULE_TYPE_PLUGIN
425 );
426
427 $products_cleaners[ WP_FS__MODULE_TYPE_THEME ] = new FS_Product_Garbage_Collector(
428 $_accounts,
429 array(
430 'theme_sites',
431 'theme_plans',
432 'themes',
433 ),
434 WP_FS__MODULE_TYPE_THEME
435 );
436
437 return $products_cleaners;
438 }
439 }