PluginProbe
Stream – Activity Log & Audit Trail / 2.0.2
Stream – Activity Log & Audit Trail v2.0.2
4.4.0 4.3.0 4.2.2 4.2.1 trunk 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.1 3.1.1 3.10.0 3.2.0 3.2.1 3.2.2 3.2.3 All 50 releases
stream / classes / class-wp-stream-migrate.php

class-wp-stream-migrate.php in Stream – Activity Log & Audit Trail 2.0.2, at classes/class-wp-stream-migrate.php

584 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 class WP_Stream_Migrate {
4
5 /**
6 * Migrate delay transient name/identifier used when user wants to be reminded to migrate later
7 */
8 const MIGRATE_DELAY_TRANSIENT = 'wp_stream_migrate_delayed';
9
10 /**
11 * Hold the current site ID
12 *
13 * @var int
14 */
15 public static $site_id = 1;
16
17 /**
18 * Hold the current blog ID
19 *
20 * @var int
21 */
22 public static $blog_id = 1;
23
24 /**
25 * Hold the total number of legacy records found in the DB
26 *
27 * @var int
28 */
29 public static $record_count = 0;
30
31 /**
32 * Limit payload chunks to a certain number of records
33 *
34 * @var int
35 */
36 public static $limit = 0;
37
38 /**
39 * Hold unformatted records temporarily for deletion
40 *
41 * @var array
42 */
43 private static $_records = array();
44
45 /**
46 * Check that legacy data exists before doing anything
47 *
48 * @return void
49 */
50 public static function load() {
51 // Exit early if there is no option holding the DB version
52 if ( false === get_site_option( 'wp_stream_db' ) ) {
53 return;
54 }
55
56 global $wpdb;
57
58 // If there are no legacy tables found, then attempt to clear all legacy data and exit early
59 if ( null === $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->base_prefix}stream'" ) ) {
60 self::drop_legacy_data( false );
61 return;
62 }
63
64 self::$site_id = is_multisite() ? get_current_site()->id : 1;
65 self::$blog_id = get_current_blog_id();
66
67 self::$record_count = $wpdb->get_var(
68 $wpdb->prepare( "
69 SELECT COUNT(*)
70 FROM {$wpdb->base_prefix}stream AS s, {$wpdb->base_prefix}stream_context AS sc
71 WHERE s.site_id = %d
72 AND s.blog_id = %d
73 AND s.type = 'stream'
74 AND sc.record_id = s.ID
75 ",
76 self::$site_id,
77 self::$blog_id
78 )
79 );
80
81 // If there are no legacy records for this site/blog, then attempt to clear all legacy data and exit early
82 if ( 0 === self::$record_count ) {
83 self::drop_legacy_data();
84 return;
85 }
86
87 self::$limit = apply_filters( 'wp_stream_migrate_chunk_size', 100 );
88
89 add_action( 'admin_notices', array( __CLASS__, 'migrate_notice' ), 9 );
90
91 add_action( 'wp_ajax_wp_stream_migrate_action', array( __CLASS__, 'process_migrate_action' ) );
92 }
93
94 /**
95 * Give the user options for how to handle their legacy Stream records
96 *
97 * @action admin_notices
98 * @return void
99 */
100 public static function show_migrate_notice() {
101 if ( ! isset( $_GET['migrate_action'] ) && WP_Stream::is_connected() && WP_Stream_Admin::is_stream_screen() && ! empty( self::$record_count ) && false === get_transient( self::MIGRATE_DELAY_TRANSIENT ) ) {
102 return true;
103 }
104
105 return false;
106 }
107
108 /**
109 * Give the user options for how to handle their legacy Stream records
110 *
111 * @action admin_notices
112 * @return void
113 */
114 public static function migrate_notice() {
115 if ( ! self::show_migrate_notice() ) {
116 return;
117 }
118
119 $notice = sprintf(
120 '<strong id="stream-migrate-title">%s</strong></p><p id="stream-migrate-message">%s</p><div id="stream-migrate-progress"><progress value="0" max="100"></progress> <strong>0&#37;</strong> <em></em> <button id="stream-migrate-actions-close" class="button button-secondary">%s</button><div class="clear"></div></div><p id="stream-migrate-actions"><button id="stream-start-migrate" class="button button-primary">%s</button> <button id="stream-migrate-reminder" class="button button-secondary">%s</button> <a href="#" id="stream-delete-records" class="delete">%s</a>',
121 __( 'Migrate Stream Records', 'stream' ),
122 sprintf( __( 'We found %s existing Stream records that need to be migrated to your Stream account.', 'stream' ), number_format( self::$record_count ) ),
123 __( 'Close', 'stream' ),
124 __( 'Start Migration Now', 'stream' ),
125 __( 'Remind Me Later', 'stream' ),
126 __( 'Delete Existing Records', 'stream' )
127 );
128
129 WP_Stream::notice( $notice, false );
130 }
131
132 /**
133 * Ajax callback for processing migrate actions
134 *
135 * Break down the total number of records found into reasonably-sized chunks
136 * and send each of those chunks to the Stream API
137 *
138 * Drops the legacy Stream data from the DB once the API has consumed everything
139 *
140 * @action wp_ajax_wp_stream_migrate_action
141 * @return void
142 */
143 public static function process_migrate_action() {
144 $action = wp_stream_filter_input( INPUT_POST, 'migrate_action' );
145 $nonce = wp_stream_filter_input( INPUT_POST, 'nonce' );
146
147 if ( ! wp_verify_nonce( $nonce, 'wp_stream_migrate-' . absint( get_current_blog_id() ) . absint( get_current_user_id() ) ) ) {
148 return;
149 }
150
151 set_time_limit( 0 ); // Just in case, this could take a while for some
152
153 if ( 'migrate' === $action ) {
154 self::migrate_notification_rules();
155
156 $records = self::get_records( self::$limit );
157
158 if ( ! $records ) {
159 // If all the records are gone, clean everything up
160 self::drop_legacy_data();
161
162 wp_send_json_success( __( 'Migration complete!', 'stream' ) );
163 }
164
165 $response = self::send_records( $records );
166
167 if ( true === $response ) {
168 // Delete the records that were just sent to the API successfully
169 self::delete_records( self::$_records );
170
171 wp_send_json_success( 'migrate' );
172 } else {
173 if ( isset( $response['body']['message'] ) && ! empty( $response['body']['message'] ) ) {
174 $body = json_decode( $response['body'], true );
175 $message = $body['message'];
176 } elseif ( isset( $response['response']['message'] ) && ! empty( $response['response']['message'] ) ) {
177 $message = $response['response']['message'];
178 } else {
179 $message = __( 'An unknown error occurred during migration.', 'stream' );
180 }
181
182 wp_send_json_error( sprintf( __( '%s Please try again later or contact support.', 'stream' ), esc_html( $message ) ) );
183 }
184 }
185
186 if ( 'delay' === $action ) {
187 set_transient( self::MIGRATE_DELAY_TRANSIENT, "Don't nag me, bro", HOUR_IN_SECONDS * 3 );
188
189 wp_send_json_success( __( "OK, we'll remind you again in a few hours.", 'stream' ) );
190 }
191
192 if ( 'delete' === $action ) {
193 $success_message = __( 'All existing records have been deleted from the database.', 'stream' );
194
195 if ( ! is_multisite() ) {
196 // If this is a single-site install, force delete everything
197 self::drop_legacy_data( true, true );
198
199 wp_send_json_success( $success_message );
200 } else {
201 // If multisite, only delete records for this site - this will take longer
202 $records = self::get_record_ids( self::$limit );
203
204 if ( ! $records ) {
205 // If all the records are gone, clean everything up
206 self::drop_legacy_data();
207
208 wp_send_json_success( $success_message );
209 } else {
210 self::delete_records( $records );
211
212 wp_send_json_success( 'delete' );
213 }
214 }
215 }
216
217 die();
218 }
219
220 /**
221 * Migrate notification_rule records to the new custom post type
222 *
223 * @return void
224 */
225 private static function migrate_notification_rules() {
226 global $wpdb;
227
228 // Blog ID is set to 0 on single site installs
229 $blog_id = is_multisite() ? self::$blog_id : 0;
230
231 $rules = $wpdb->get_results(
232 $wpdb->prepare( "
233 SELECT *
234 FROM {$wpdb->base_prefix}stream
235 WHERE site_id = %d
236 AND blog_id = %d
237 AND type = 'notification_rule'
238 ORDER BY created DESC
239 ",
240 self::$site_id,
241 $blog_id
242 ),
243 ARRAY_A
244 );
245
246 if ( empty( $rules ) ) {
247 return;
248 }
249
250 foreach ( $rules as $rule => $data ) {
251 $rule_post_args = array();
252 $rule_post_meta = array();
253
254 // Set args for the new rule post
255 $rule_post_args['post_title'] = $rules[ $rule ]['summary'];
256 $rule_post_args['post_type'] = WP_Stream_Notifications_Post_Type::POSTTYPE;
257 $rule_post_args['post_status'] = ( 'active' === $rules[ $rule ]['visibility'] ) ? 'publish' : 'draft';
258 $rule_post_args['post_date'] = get_date_from_gmt( $rules[ $rule ]['created'] );
259 $rule_post_args['post_date_gmt'] = $rules[ $rule ]['created']; // May not work, known bug in WP, see workaround below
260 $rule_post_args['comment_status'] = 'closed';
261 $rule_post_args['ping_status'] = 'closed';
262
263 // Get rule meta
264 $stream_rule_meta = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$wpdb->base_prefix}stream_meta WHERE record_id = %d", $rules[ $rule ]['ID'] ), ARRAY_A );
265
266 // Prepare meta values for rule post meta
267 foreach ( $stream_rule_meta as $meta => $value ) {
268 $rule_post_meta[ $value['meta_key'] ] = maybe_unserialize( $value['meta_value'] );
269 }
270
271 // Get rule option, which is automatically unserialized
272 $stream_rule_option = get_option( 'stream_notifications_' . absint( $rules[ $rule ]['ID'] ) );
273
274 // Prepare option values for rule post meta
275 $rule_post_meta['triggers'] = isset( $stream_rule_option['triggers'] ) ? $stream_rule_option['triggers'] : array();
276 $rule_post_meta['groups'] = isset( $stream_rule_option['groups'] ) ? $stream_rule_option['groups'] : array();
277 $rule_post_meta['alerts'] = isset( $stream_rule_option['alerts'] ) ? $stream_rule_option['alerts'] : array();
278
279 // Insert rule as a new post
280 $post_id = wp_insert_post( $rule_post_args );
281
282 // Workaround to fix bug in wp_insert_post() not honoring the `post_date_gmt` arg
283 // See: https://core.trac.wordpress.org/ticket/15946
284 $wpdb->update( $wpdb->prefix . 'posts', array( 'post_date_gmt' => $rules[ $rule ]['created'] ), array( 'ID' => $post_id ), array( '%s' ), array( '%d' ) );
285
286 // Save the rule post meta
287 foreach ( $rule_post_meta as $key => $value ) {
288 update_post_meta( $post_id, $key, $value );
289 }
290
291 // Delete the old option
292 delete_option( 'stream_notifications_' . absint( $rules[ $rule ]['ID'] ) );
293 }
294
295 // No need for chunks since there likely won't be more than a few dozen rules
296 self::delete_records( $rules );
297 }
298
299 /**
300 * Send records to the API
301 *
302 * @param array $records
303 *
304 * @return mixed True on success, the full response array on failure.
305 */
306 private static function send_records( $records ) {
307 if ( empty( $records ) || ! WP_Stream::$api->site_uuid ) {
308 return false;
309 }
310
311 $url = WP_Stream::$api->request_url( sprintf( '/sites/%s/records', urlencode( WP_Stream::$api->site_uuid ) ) );
312 $args = array(
313 'method' => 'POST',
314 'body' => json_encode( array( 'records' => $records ) ),
315 'sslverify' => true,
316 'blocking' => true,
317 'headers' => array(
318 'Content-Type' => 'application/json',
319 'Accept-Version' => WP_Stream::$api->api_version,
320 'Stream-Site-API-Key' => WP_Stream::$api->api_key,
321 ),
322 );
323
324 $response = wp_remote_request( $url, $args );
325
326 // Loose comparison needed
327 if ( ! is_wp_error( $response ) && isset( $response['response']['code'] ) && 201 == $response['response']['code'] ) {
328 return true;
329 } else {
330 return (array) $response;
331 }
332 }
333
334 /**
335 * Get a chunk of records formatted for Stream API ingestion
336 *
337 * @param int $limit The number of rows to query
338 *
339 * @return mixed An array of record arrays, or FALSE if no records were found
340 */
341 private static function get_records( $limit = null ) {
342 $limit = is_int( $limit ) ? $limit : self::$limit;
343
344 global $wpdb;
345
346 $records = $wpdb->get_results(
347 $wpdb->prepare( "
348 SELECT s.*, sc.connector, sc.context, sc.action
349 FROM {$wpdb->base_prefix}stream AS s, {$wpdb->base_prefix}stream_context AS sc
350 WHERE s.site_id = %d
351 AND s.blog_id = %d
352 AND s.type = 'stream'
353 AND sc.record_id = s.ID
354 ORDER BY s.created DESC
355 LIMIT %d
356 ",
357 self::$site_id,
358 self::$blog_id,
359 $limit
360 ),
361 ARRAY_A
362 );
363
364 if ( empty( $records ) ) {
365 return false;
366 }
367
368 self::$_records = array();
369
370 foreach ( $records as $record => $data ) {
371 $stream_meta = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$wpdb->base_prefix}stream_meta WHERE record_id = %d", $records[ $record ]['ID'] ), ARRAY_A );
372 $stream_meta_output = array();
373 $author_meta_output = array();
374
375 foreach ( $stream_meta as $key => $meta ) {
376
377 if ( 'author_meta' === $meta['meta_key'] && ! empty( $meta['meta_value'] ) ) {
378 $author_meta_output = maybe_unserialize( $meta['meta_value'] );
379
380 unset( $stream_meta[ $key ] );
381
382 continue;
383 }
384
385 // Unserialize meta first so we can then check for malformed serialized strings
386 $stream_meta_output[ $meta['meta_key'] ] = maybe_unserialize( $meta['meta_value'] );
387
388 // If any serialized data is still lingering in the meta value that means it's malformed and should be removed
389 if (
390 is_string( $stream_meta_output[ $meta['meta_key'] ] )
391 &&
392 1 === preg_match( '/(a|O) ?\x3a ?[0-9]+ ?\x3a ?\x7b/', $stream_meta_output[ $meta['meta_key'] ] )
393 ) {
394 unset( $stream_meta_output[ $meta['meta_key'] ] );
395
396 continue;
397 }
398
399 // All meta must be strings, so serialize any array meta values again
400 $stream_meta_output[ $meta['meta_key'] ] = (string) maybe_serialize( $stream_meta_output[ $meta['meta_key'] ] );
401 }
402
403 // All author meta must be strings
404 array_walk(
405 $author_meta_output,
406 function( &$v ) {
407 $v = (string) $v;
408 }
409 );
410
411 $records[ $record ]['stream_meta'] = $stream_meta_output;
412 $records[ $record ]['author_meta'] = $author_meta_output;
413
414 self::$_records[] = $records[ $record ];
415
416 $records[ $record ]['created'] = wp_stream_get_iso_8601_extended_date( strtotime( $records[ $record ]['created'] ) );
417
418 unset( $records[ $record ]['ID'] );
419 unset( $records[ $record ]['parent'] );
420
421 // Ensure required fields always exist
422 $records[ $record ]['site_id'] = ! empty( $records[ $record ]['site_id'] ) ? $records[ $record ]['site_id'] : 1;
423 $records[ $record ]['blog_id'] = ! empty( $records[ $record ]['blog_id'] ) ? $records[ $record ]['blog_id'] : 1;
424 $records[ $record ]['object_id'] = ! empty( $records[ $record ]['object_id'] ) ? $records[ $record ]['object_id'] : 0;
425 $records[ $record ]['author'] = ! empty( $records[ $record ]['author'] ) ? $records[ $record ]['author'] : 0;
426 $records[ $record ]['author_role'] = ! empty( $records[ $record ]['author_role'] ) ? $records[ $record ]['author_role'] : '';
427 $records[ $record ]['ip'] = ! empty( $records[ $record ]['ip'] ) ? $records[ $record ]['ip'] : '';
428 }
429
430 return $records;
431 }
432
433 /**
434 * Get a chunk of record IDs
435 *
436 * @param int $limit The number of rows to query
437 *
438 * @return mixed An array of record IDs, or FALSE if no records were found
439 */
440 private static function get_record_ids( $limit = null ) {
441 $limit = is_int( $limit ) ? $limit : self::$limit;
442
443 global $wpdb;
444
445 $records = $wpdb->get_col(
446 $wpdb->prepare( "
447 SELECT s.ID
448 FROM {$wpdb->base_prefix}stream AS s
449 WHERE s.site_id = %d
450 AND s.blog_id = %d
451 AND s.type = 'stream'
452 LIMIT %d
453 ",
454 self::$site_id,
455 self::$blog_id,
456 $limit
457 )
458 );
459
460 if ( empty( $records ) ) {
461 return false;
462 }
463
464 return $records;
465 }
466
467 /**
468 * Drop the legacy Stream records from the database for the current site/blog
469 *
470 * @param array $records An array of record arrays.
471 *
472 * @return void
473 */
474 private static function delete_records( $records ) {
475 if ( empty( $records ) ) {
476 return;
477 }
478
479 global $wpdb;
480
481 // Delete legacy rows from each Stream table for these records only
482 foreach ( $records as $record ) {
483 // Get the record ID from an array of records, or from an array of IDs
484 if ( isset( $record['ID'] ) ) {
485 $record_id = $record['ID'];
486 } elseif ( is_numeric( $record ) ) {
487 $record_id = $record;
488 } else {
489 $record_id = false;
490 }
491
492 if ( empty( $record_id ) ) {
493 continue;
494 }
495
496 $wpdb->delete( $wpdb->base_prefix . 'stream', array( 'ID' => $record_id ), array( '%d' ) );
497 $wpdb->delete( $wpdb->base_prefix . 'stream_context', array( 'record_id' => $record_id ), array( '%d' ) );
498 $wpdb->delete( $wpdb->base_prefix . 'stream_meta', array( 'record_id' => $record_id ), array( '%d' ) );
499 }
500 }
501
502 /**
503 * Drop the legacy Stream tables and options from the database
504 *
505 * @param bool $drop_tables If true, attempt to drop the legacy Stream tables
506 * @param bool $force If true, delete tables even if records still exist
507 *
508 * @return void
509 */
510 private static function drop_legacy_data( $drop_tables = true, $force = false ) {
511 global $wpdb;
512
513 if ( $drop_tables ) {
514 if ( is_multisite() ) {
515 $stream_site_blog_pairs = $wpdb->get_results( "SELECT site_id, blog_id FROM {$wpdb->base_prefix}stream WHERE type = 'stream'", ARRAY_A );
516 $stream_site_blog_pairs = array_unique( array_map( 'self::implode_key_value', $stream_site_blog_pairs ) );
517 $wp_site_blog_pairs = $wpdb->get_results( "SELECT site_id, blog_id FROM {$wpdb->base_prefix}blogs", ARRAY_A );
518 $wp_site_blog_pairs = array_unique( array_map( 'self::implode_key_value', $wp_site_blog_pairs ) );
519 $records_exist = ( array_intersect( $stream_site_blog_pairs, $wp_site_blog_pairs ) ) ? true : false;
520 } else {
521 $records_exist = $wpdb->get_var( "SELECT * FROM `{$wpdb->prefix}stream` LIMIT 1" );
522 }
523
524 // If records exist for other sites/blogs then don't proceed, unless we're force deleting or those sites/blogs have been deleted
525 if ( $records_exist && ! $force ) {
526 return;
527 }
528
529 // Drop legacy tables
530 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->base_prefix}stream, {$wpdb->base_prefix}stream_context, {$wpdb->base_prefix}stream_meta" );
531 }
532
533 // Delete legacy multisite options
534 if ( is_multisite() ) {
535 $blogs = wp_get_sites();
536
537 foreach ( $blogs as $blog ) {
538 switch_to_blog( $blog['blog_id'] );
539 delete_option( plugin_basename( WP_STREAM_DIR ) . '_db' ); // Deprecated option key
540 delete_option( 'wp_stream_db' );
541 delete_option( 'wp_stream_license' );
542 delete_option( 'wp_stream_licensee' );
543 }
544
545 restore_current_blog();
546 }
547
548 // Delete legacy options
549 delete_site_option( plugin_basename( WP_STREAM_DIR ) . '_db' ); // Deprecated option key
550 delete_site_option( 'wp_stream_db' );
551 delete_site_option( 'wp_stream_license' );
552 delete_site_option( 'wp_stream_licensee' );
553
554 // Delete legacy transients
555 delete_transient( 'wp_stream_extensions_' );
556
557 // Delete legacy cron event hooks
558 wp_clear_scheduled_hook( 'stream_auto_purge' ); // Deprecated hook
559 wp_clear_scheduled_hook( 'wp_stream_auto_purge' );
560 }
561
562 /**
563 * Callback to impode key/value pairs from an associative array into a specially-formatted string
564 *
565 * @param array $array An associate array
566 *
567 * @return string $output
568 */
569 public static function implode_key_value( $array ) {
570 $output = implode( ', ',
571 array_map(
572 function ( $v, $k ) {
573 return sprintf( '%s:%s', $k, $v );
574 },
575 $array,
576 array_keys( $array )
577 )
578 );
579
580 return $output;
581 }
582
583 }
584