| 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 |
} |
| 468 |
|