| @@ -1,0 +1,467 @@ | ||
| 1 | +<?php | |
| 2 | +namespace NotificationX\Admin; | |
| 3 | + | |
| 4 | +use NotificationX\Core\Database; | |
| 5 | +use NotificationX\Core\PostType; | |
| 6 | +use NotificationX\Core\Rules; | |
| 7 | +use NotificationX\Extensions\GlobalFields; | |
| 8 | +use NotificationX\GetInstance; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * @method static ImportExport get_instance($args = null) | |
| 12 | + */ | |
| 13 | +class ImportExport{ | |
| 14 | + use GetInstance; | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * Elementor meta keys that may cross the import/export boundary. | |
| 18 | + * | |
| 19 | + * Everything else is dropped. On import the incoming array used to be | |
| 20 | + * looped verbatim into `add_post_meta()`, which let a caller write any meta | |
| 21 | + * key it liked onto a post it had just created; on export every meta row of | |
| 22 | + * the linked post was returned, which leaked whatever other plugins store | |
| 23 | + * there. | |
| 24 | + */ | |
| 25 | + const ELEMENTOR_META_ALLOWLIST = [ | |
| 26 | + '_elementor_data', | |
| 27 | + '_elementor_edit_mode', | |
| 28 | + '_elementor_template_type', | |
| 29 | + '_elementor_page_settings', | |
| 30 | + '_elementor_version', | |
| 31 | + '_wp_page_template', | |
| 32 | + ]; | |
| 33 | + | |
| 34 | + public function __construct(){ | |
| 35 | + add_filter('nx_settings_tab_miscellaneous', [$this, 'settings_tab_help']); | |
| 36 | + add_filter('upload_mimes', [$this, 'cc_mime_types']); | |
| 37 | + add_filter('nx_settings', [$this, 'save_settings']); | |
| 38 | + } | |
| 39 | + | |
| 40 | + public function save_settings($settings) { | |
| 41 | + $remove_before_save = [ | |
| 42 | + 'export-notification', | |
| 43 | + 'export-analytics', | |
| 44 | + 'export-status', | |
| 45 | + 'export-settings', | |
| 46 | + 'run_export', | |
| 47 | + 'import', | |
| 48 | + 'run_import', | |
| 49 | + ]; | |
| 50 | + foreach ($remove_before_save as $key) { | |
| 51 | + if(isset($settings[$key])){ | |
| 52 | + unset($settings[$key]); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + return $settings; | |
| 56 | + } | |
| 57 | + | |
| 58 | + public function cc_mime_types($mimes) { | |
| 59 | + $mimes['json'] = 'text/plain'; | |
| 60 | + return $mimes; | |
| 61 | + } | |
| 62 | + | |
| 63 | + public function settings_tab_help($tabs) { | |
| 64 | + | |
| 65 | + $tabs['fields']['import-section'] = array( | |
| 66 | + 'name' => 'import-section', | |
| 67 | + 'type' => "section", | |
| 68 | + 'label' => __('Import/Export', 'notificationx'), | |
| 69 | + 'priority' => 30, | |
| 70 | + 'fields' => array( | |
| 71 | + 'export-notification' => [ | |
| 72 | + 'name' => "export-notification", | |
| 73 | + 'type' => 'checkbox', | |
| 74 | + 'label' => __('Export Notifications', 'notificationx'), | |
| 75 | + 'default' => 0, | |
| 76 | + 'priority' => 10, | |
| 77 | + ], | |
| 78 | + 'export-analytics' => [ | |
| 79 | + 'name' => "export-analytics", | |
| 80 | + 'type' => 'checkbox', | |
| 81 | + 'label' => __('Analytics', 'notificationx'), | |
| 82 | + 'default' => 0, | |
| 83 | + 'priority' => 15, | |
| 84 | + 'rules' => Rules::is( 'export-notification', true ), | |
| 85 | + // 'description' => __('Click, if you want to disable powered by text from notification', 'notificationx'), | |
| 86 | + ], | |
| 87 | + 'export-status' => array( | |
| 88 | + 'name' => 'export-status', | |
| 89 | + 'type' => 'select', | |
| 90 | + 'label' => __('Status', 'notificationx'), | |
| 91 | + 'priority' => 20, | |
| 92 | + 'rules' => Rules::is( 'export-notification', true ), | |
| 93 | + 'default' => ['all'], | |
| 94 | + 'options' => GlobalFields::get_instance()->normalize_fields([ | |
| 95 | + 'all' => 'ALL', | |
| 96 | + 'enabled' => 'Enabled', | |
| 97 | + 'disabled' => 'Disabled', | |
| 98 | + ]), | |
| 99 | + ), | |
| 100 | + 'export-settings' => [ | |
| 101 | + 'name' => "export-settings", | |
| 102 | + 'type' => 'checkbox', | |
| 103 | + 'label' => __('Export Settings', 'notificationx'), | |
| 104 | + 'default' => 0, | |
| 105 | + 'priority' => 30, | |
| 106 | + ], | |
| 107 | + 'run_export' => array( | |
| 108 | + 'name' => 'run_export', | |
| 109 | + // 'label' => __('Import', 'notificationx'), | |
| 110 | + 'text' => [ | |
| 111 | + 'normal' => __('Export', 'notificationx'), | |
| 112 | + 'saved' => __('Export', 'notificationx'), | |
| 113 | + 'loading' => __('Exporting...', 'notificationx'), | |
| 114 | + ], | |
| 115 | + 'type' => 'button', | |
| 116 | + 'priority' => 40, | |
| 117 | + // 'rules' => Rules::is( 'import', null, true ), | |
| 118 | + 'rules' => Rules::logicalRule([ | |
| 119 | + Rules::is( 'export-notification', true ), | |
| 120 | + Rules::is( 'export-settings', true ), | |
| 121 | + ], 'or'), | |
| 122 | + 'ajax' => [ | |
| 123 | + 'on' => 'click', | |
| 124 | + 'api' => '/notificationx/v1/export', | |
| 125 | + 'data' => [ | |
| 126 | + 'export-notification' => '@export-notification', | |
| 127 | + 'export-settings' => '@export-settings', | |
| 128 | + 'export-analytics' => '@export-analytics', | |
| 129 | + 'export-status' => '@export-status', | |
| 130 | + ], | |
| 131 | + 'swal' => [ | |
| 132 | + 'text' => __('Export completed successfully.', 'notificationx'), | |
| 133 | + 'icon' => 'success', | |
| 134 | + 'autoClose' => 2000 | |
| 135 | + ], | |
| 136 | + ], | |
| 137 | + ), | |
| 138 | + | |
| 139 | + 'import' => array( | |
| 140 | + 'name' => 'import', | |
| 141 | + 'type' => 'jsonuploader', | |
| 142 | + 'label' => __('Import (*.json)', 'notificationx'), | |
| 143 | + 'reset' => __('Change', 'notificationx'), | |
| 144 | + 'priority' => 60, | |
| 145 | + 'notImage' => true, | |
| 146 | + ), | |
| 147 | + 'run_import' => array( | |
| 148 | + 'name' => 'run_import', | |
| 149 | + // 'label' => __('Import', 'notificationx'), | |
| 150 | + 'text' => [ | |
| 151 | + 'normal' => __('Import', 'notificationx'), | |
| 152 | + 'saved' => __('Import', 'notificationx'), | |
| 153 | + 'loading' => __('Importing...', 'notificationx'), | |
| 154 | + ], | |
| 155 | + 'type' => 'button', | |
| 156 | + 'priority' => 70, | |
| 157 | + 'rules' => Rules::is( 'import', null, true ), | |
| 158 | + 'ajax' => [ | |
| 159 | + 'on' => 'click', | |
| 160 | + 'api' => '/notificationx/v1/import', | |
| 161 | + 'data' => [ | |
| 162 | + 'import' => '@import', | |
| 163 | + ], | |
| 164 | + 'swal' => [ | |
| 165 | + 'text' => __('Import completed successfully.', 'notificationx'), | |
| 166 | + 'icon' => 'success', | |
| 167 | + 'autoClose' => 2000 | |
| 168 | + ], | |
| 169 | + ], | |
| 170 | + ), | |
| 171 | + ), | |
| 172 | + ); | |
| 173 | + | |
| 174 | + return $tabs; | |
| 175 | + } | |
| 176 | + | |
| 177 | + public function import($request){ | |
| 178 | + // Importing/exporting many notifications can exceed the default limit. | |
| 179 | + // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged | |
| 180 | + @set_time_limit(0); | |
| 181 | + $params = $request->get_params(); | |
| 182 | + $status = 'error'; | |
| 183 | + if(!empty($params['import'])){ | |
| 184 | + try { | |
| 185 | + $data = json_decode($params['import'], true); | |
| 186 | + | |
| 187 | + if(!empty($data['settings'])){ | |
| 188 | + /* | |
| 189 | + * This route resolves `edit_notificationx`, but replacing the | |
| 190 | + * settings blob is settings authority. Writing through | |
| 191 | + * `set()` also skipped the capability check, the `nx_settings` | |
| 192 | + * filter and `preserve_protected_settings()` that the real | |
| 193 | + * save path applies -- so import was a way around every guard | |
| 194 | + * on `/settings`. Go through `save_settings()` instead. | |
| 195 | + */ | |
| 196 | + if ( ! current_user_can( 'edit_notificationx_settings' ) ) { | |
| 197 | + return new \WP_Error( | |
| 198 | + 'nx_forbidden_settings_import', | |
| 199 | + __( 'You are not allowed to import NotificationX settings.', 'notificationx' ), | |
| 200 | + [ 'status' => 403 ] | |
| 201 | + ); | |
| 202 | + } | |
| 203 | + Settings::get_instance()->save_settings( $data['settings'] ); | |
| 204 | + $status = 'success'; | |
| 205 | + } | |
| 206 | + | |
| 207 | + if(!empty($data['notifications'])){ | |
| 208 | + $analytics = []; | |
| 209 | + if(!empty($data['analytics'])){ | |
| 210 | + $analytics = $this->group_stats_by_nx_id($data['analytics']); | |
| 211 | + } | |
| 212 | + foreach ($data['notifications'] as $key => $post) { | |
| 213 | + $nx_id = $post['nx_id']; | |
| 214 | + unset($post['nx_id']); | |
| 215 | + unset($post['id']); | |
| 216 | + | |
| 217 | + if(isset($post['source']) && $post['source'] == 'press_bar' && !empty($post['elementor_id'])){ | |
| 218 | + $el_id = $this->import_elementor_document( | |
| 219 | + isset($data['elementor'][$post['elementor_id']]) ? $data['elementor'][$post['elementor_id']] : [] | |
| 220 | + ); | |
| 221 | + if($el_id){ | |
| 222 | + $post['elementor_id'] = $el_id; | |
| 223 | + } | |
| 224 | + else{ | |
| 225 | + unset($post['elementor_id']); | |
| 226 | + } | |
| 227 | + } | |
| 228 | + | |
| 229 | + | |
| 230 | + $notification = PostType::get_instance()->save_post($post); //, ['no_hooks' => true] | |
| 231 | + $nx_id_new = $notification['nx_id']; | |
| 232 | + | |
| 233 | + if(!empty($analytics[$nx_id])){ | |
| 234 | + foreach ($analytics[$nx_id] as $key => $value) { | |
| 235 | + $value['nx_id'] = $nx_id_new; | |
| 236 | + $analytics[$nx_id][$key] = $value; | |
| 237 | + } | |
| 238 | + // Database::get_instance()->insert_posts(Database::$table_stats, array_values($analytics[$nx_id])); | |
| 239 | + } | |
| 240 | + } | |
| 241 | + if(!empty($analytics)){ | |
| 242 | + $_analytics = []; | |
| 243 | + foreach ($analytics as $key => $value) { | |
| 244 | + $_analytics = array_merge($_analytics, $value); | |
| 245 | + } | |
| 246 | + Database::get_instance()->insert_posts(Database::$table_stats, array_values($_analytics)); | |
| 247 | + } | |
| 248 | + | |
| 249 | + $status = 'success'; | |
| 250 | + } | |
| 251 | + | |
| 252 | + } catch (\Throwable $th) { | |
| 253 | + //throw $th; | |
| 254 | + $status = 'error'; | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + return [ | |
| 259 | + 'status' => $status, | |
| 260 | + 'data' => [ | |
| 261 | + 'context' => [ | |
| 262 | + 'import' => null, | |
| 263 | + ] | |
| 264 | + ] | |
| 265 | + ]; | |
| 266 | + } | |
| 267 | + | |
| 268 | + public function export($request){ | |
| 269 | + // Importing/exporting many notifications can exceed the default limit. | |
| 270 | + // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged | |
| 271 | + @set_time_limit(0); | |
| 272 | + $params = $request->get_params(); | |
| 273 | + $export = []; | |
| 274 | + if(!empty($params['export-settings'])){ | |
| 275 | + if ( ! current_user_can( 'edit_notificationx_settings' ) ) { | |
| 276 | + return new \WP_Error( | |
| 277 | + 'nx_forbidden_settings_export', | |
| 278 | + __( 'You are not allowed to export NotificationX settings.', 'notificationx' ), | |
| 279 | + [ 'status' => 403 ] | |
| 280 | + ); | |
| 281 | + } | |
| 282 | + $file_name = 'nx-settings-export.json'; | |
| 283 | + /* | |
| 284 | + * Credentials never travel in an export file. The download lands in | |
| 285 | + * a Downloads folder and gets attached to support tickets; a live | |
| 286 | + * OAuth refresh token or API key in there outlives any access | |
| 287 | + * control the site applies. Import restores whatever the target site | |
| 288 | + * already had, so a round trip does not blank integrations. | |
| 289 | + */ | |
| 290 | + $export['settings'] = Settings::redact_secret_settings( Settings::get_instance()->get('settings') ); | |
| 291 | + } | |
| 292 | + if(!empty($params['export-notification'])){ | |
| 293 | + $where = []; | |
| 294 | + $file_name = 'nx-notification-export.json'; | |
| 295 | + if(!empty($params['export-status']) && ($params['export-status'] == 'enabled' || $params['export-status'] == 'disabled')){ | |
| 296 | + $where = [ | |
| 297 | + 'enabled' => $params['export-status'] == 'enabled', | |
| 298 | + ]; | |
| 299 | + } | |
| 300 | + if(!empty($params['export-notification-ids']) && is_array($params['export-notification-ids'])){ | |
| 301 | + $where = [ | |
| 302 | + 'nx_id' => [ | |
| 303 | + 'IN', | |
| 304 | + $params['export-notification-ids'], | |
| 305 | + ], | |
| 306 | + ]; | |
| 307 | + } | |
| 308 | + $export['notifications'] = PostType::get_instance()->get_posts($where); | |
| 309 | + if(!empty($params['export-analytics']) && !empty($export['notifications'])){ | |
| 310 | + $nx_ids = array_column($export['notifications'], 'nx_id'); | |
| 311 | + $export['analytics'] = Database::get_instance()->get_posts(Database::$table_stats, '*', [ | |
| 312 | + 'nx_id' => [ 'IN', $nx_ids ], | |
| 313 | + ]); | |
| 314 | + } | |
| 315 | + | |
| 316 | + if(!empty($export['notifications'])){ | |
| 317 | + foreach ($export['notifications'] as $post) { | |
| 318 | + if(isset($post['source']) && $post['source'] == 'press_bar' && !empty($post['elementor_id'])){ | |
| 319 | + /* | |
| 320 | + * `elementor_id` is stored inside the notification's own | |
| 321 | + * data blob, which is whatever the client submitted, and | |
| 322 | + * `get_posts()` merges that blob up to the top level. So | |
| 323 | + * this ID is attacker-controlled: without the type check | |
| 324 | + * an `edit_notificationx` user could point it at any post | |
| 325 | + * and read it back, with every meta row attached. | |
| 326 | + */ | |
| 327 | + $linked = get_post( $post['elementor_id'] ); | |
| 328 | + if ( ! $linked || 'nx_bar' !== $linked->post_type ) { | |
| 329 | + continue; | |
| 330 | + } | |
| 331 | + | |
| 332 | + $export['elementor'][$post['elementor_id']]['post'] = $linked; | |
| 333 | + $meta = get_post_meta($post['elementor_id']); | |
| 334 | + foreach ($meta as $meta_key => $value) { | |
| 335 | + if ( ! in_array( $meta_key, self::ELEMENTOR_META_ALLOWLIST, true ) ) { | |
| 336 | + continue; | |
| 337 | + } | |
| 338 | + $export['elementor'][$post['elementor_id']]['meta'][$meta_key] = array_map('maybe_unserialize', $value); | |
| 339 | + } | |
| 340 | + } | |
| 341 | + } | |
| 342 | + } | |
| 343 | + } | |
| 344 | + if(!empty($params['export-settings']) && !empty($params['export-notification'])){ | |
| 345 | + $file_name = 'nx-export.json'; | |
| 346 | + } | |
| 347 | + return [ | |
| 348 | + 'success' => true, | |
| 349 | + 'data' => [ | |
| 350 | + 'filename' => $file_name, | |
| 351 | + 'download' => $export, | |
| 352 | + 'context' => [ | |
| 353 | + 'export-notification' => false, | |
| 354 | + 'export-settings' => false, | |
| 355 | + 'export-analytics' => false, | |
| 356 | + 'export-status' => 'all', | |
| 357 | + ] | |
| 358 | + ] | |
| 359 | + ]; | |
| 360 | + } | |
| 361 | + | |
| 362 | + /** | |
| 363 | + * Create the Elementor document that a `press_bar` notification links to. | |
| 364 | + * | |
| 365 | + * The previous implementation handed the client-supplied `post` array | |
| 366 | + * straight to `wp_insert_post()` with only `ID` removed, so `post_type`, | |
| 367 | + * `post_status` and `post_author` were all attacker-chosen -- an import file | |
| 368 | + * could publish a page, authored by anyone, from a Contributor account. The | |
| 369 | + * document is now built here and only its title is taken from the payload. | |
| 370 | + * | |
| 371 | + * @param array $document Untrusted `['post' => [...], 'meta' => [...]]`. | |
| 372 | + * @return int New post ID, or 0 when nothing was created. | |
| 373 | + */ | |
| 374 | + protected function import_elementor_document( $document ) { | |
| 375 | + if ( empty( $document['post'] ) || ! is_array( $document['post'] ) ) { | |
| 376 | + return 0; | |
| 377 | + } | |
| 378 | + | |
| 379 | + $incoming = $document['post']; | |
| 380 | + $title = isset( $incoming['post_title'] ) ? sanitize_text_field( $incoming['post_title'] ) : ''; | |
| 381 | + if ( '' === $title ) { | |
| 382 | + $title = __( 'NotificationX Bar', 'notificationx' ); | |
| 383 | + } | |
| 384 | + | |
| 385 | + $el_id = wp_insert_post( [ | |
| 386 | + 'post_title' => wp_slash( $title ), | |
| 387 | + 'post_content' => isset( $incoming['post_content'] ) ? wp_slash( (string) $incoming['post_content'] ) : '', | |
| 388 | + 'post_type' => 'nx_bar', | |
| 389 | + 'post_status' => current_user_can( 'publish_posts' ) ? 'publish' : 'pending', | |
| 390 | + 'post_author' => get_current_user_id(), | |
| 391 | + ], true ); | |
| 392 | + | |
| 393 | + if ( is_wp_error( $el_id ) || ! $el_id ) { | |
| 394 | + return 0; | |
| 395 | + } | |
| 396 | + | |
| 397 | + /* | |
| 398 | + * `_elementor_data` is a widget tree that Elementor renders on the front | |
| 399 | + * end, and `add_post_meta()` applies no sanitising of its own. Elementor | |
| 400 | + * gates raw markup on `unfiltered_html` in its own editor; mirror that | |
| 401 | + * here so an import cannot become a route to stored XSS. | |
| 402 | + */ | |
| 403 | + $allow_raw_html = current_user_can( 'unfiltered_html' ); | |
| 404 | + $meta = ( isset( $document['meta'] ) && is_array( $document['meta'] ) ) ? $document['meta'] : []; | |
| 405 | + | |
| 406 | + foreach ( $meta as $meta_key => $values ) { | |
| 407 | + if ( ! in_array( $meta_key, self::ELEMENTOR_META_ALLOWLIST, true ) ) { | |
| 408 | + continue; | |
| 409 | + } | |
| 410 | + | |
| 411 | + foreach ( (array) $values as $value ) { | |
| 412 | + if ( '_elementor_data' === $meta_key ) { | |
| 413 | + $decoded = json_decode( is_string( $value ) ? $value : wp_json_encode( $value ), true ); | |
| 414 | + if ( null === $decoded ) { | |
| 415 | + continue; | |
| 416 | + } | |
| 417 | + if ( ! $allow_raw_html ) { | |
| 418 | + $decoded = self::kses_deep( $decoded ); | |
| 419 | + } | |
| 420 | + $value = wp_slash( wp_json_encode( $decoded ) ); | |
| 421 | + } | |
| 422 | + elseif ( is_string( $value ) && ! $allow_raw_html ) { | |
| 423 | + $value = wp_kses_post( $value ); | |
| 424 | + } | |
| 425 | + | |
| 426 | + /* | |
| 427 | + * `update_` rather than `add_`: every allowlisted key is | |
| 428 | + * single-valued, and `wp_insert_post()` has already written its | |
| 429 | + * own `_wp_page_template` row. Appending left the imported value | |
| 430 | + * behind WordPress's, so `get_post_meta( ..., true )` returned | |
| 431 | + * the default and the imported template never took effect. | |
| 432 | + */ | |
| 433 | + update_post_meta( $el_id, $meta_key, $value ); | |
| 434 | + } | |
| 435 | + } | |
| 436 | + | |
| 437 | + return $el_id; | |
| 438 | + } | |
| 439 | + | |
| 440 | + /** | |
| 441 | + * Run `wp_kses_post()` over every string in a nested structure. | |
| 442 | + * | |
| 443 | + * @param mixed $value | |
| 444 | + * @return mixed | |
| 445 | + */ | |
| 446 | + protected static function kses_deep( $value ) { | |
| 447 | + if ( is_array( $value ) ) { | |
| 448 | + return array_map( [ __CLASS__, 'kses_deep' ], $value ); | |
| 449 | + } | |
| 450 | + if ( is_string( $value ) ) { | |
| 451 | + return wp_kses_post( $value ); | |
| 452 | + } | |
| 453 | + return $value; | |
| 454 | + } | |
| 455 | + | |
| 456 | + public function group_stats_by_nx_id($stats){ | |
| 457 | + $new_stats = []; | |
| 458 | + if(!empty($stats)){ | |
| 459 | + foreach ($stats as $key => $value) { | |
| 460 | + unset($value['stat_id']); | |
| 461 | + $new_stats[$value['nx_id']][] = $value; | |
| 462 | + } | |
| 463 | + } | |
| 464 | + | |
| 465 | + return $new_stats; | |
| 466 | + } | |
| 467 | +} | |