PluginProbe ʕ •ᴥ•ʔ
Gallery : FooGallery / 3.3.2
Gallery : FooGallery v3.3.2
3.3.2 3.3.3 3.3.0 3.1.31 3.1.32 3.1.34 3.1.36 3.2.0 3.2.6 trunk 1.10.3 2.0.24 2.1.34 2.2.44 2.3.3 2.4.32 3.0.6 3.1.11 3.1.12 3.1.13 3.1.20 3.1.25 3.1.26 3.1.26.1 3.1.26.2
foogallery / freemius / includes / class-fs-garbage-collector.php
foogallery / freemius / includes Last commit date
customizer 3 days ago debug 3 days ago entities 3 days ago managers 3 days ago sdk 3 days ago supplements 3 days ago class-freemius-abstract.php 3 days ago class-freemius.php 3 days ago class-fs-admin-notices.php 3 days ago class-fs-api.php 3 days ago class-fs-garbage-collector.php 3 days ago class-fs-hook-snapshot.php 3 days ago class-fs-lock.php 3 days ago class-fs-logger.php 3 days ago class-fs-options.php 3 days ago class-fs-plugin-updater.php 3 days ago class-fs-security.php 3 days ago class-fs-storage.php 3 days ago class-fs-user-lock.php 3 days ago fs-core-functions.php 3 days ago fs-essential-functions.php 3 days ago fs-html-escaping-functions.php 3 days ago fs-plugin-info-dialog.php 3 days ago index.php 3 days ago l10n.php 3 days 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 }