PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.2.14
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.2.14
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
notificationx / includes / Admin / ImportExport.php

ImportExport.php in NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar 3.2.14, at includes/Admin/ImportExport.php

468 lines 19.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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