PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / trunk
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar vtrunk
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
← All changes | includes/Admin/ImportExport.php +467 -0 0.2.5.6trunk View file →
@@ -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 +}