PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.2.13
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.2.13
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 / Core / Rest / Integration.php

Integration.php in NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar 3.2.13, at includes/Core/Rest/Integration.php

377 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace NotificationX\Core\Rest;
4
5 use NotificationX\Core\PostType;
6 use NotificationX\Core\REST;
7 use NotificationX\Extensions\ExtensionFactory;
8 use NotificationX\Extensions\GlobalFields;
9 use NotificationX\GetInstance;
10 use NotificationX\NotificationX;
11 use WP_REST_Controller;
12 use WP_REST_Response;
13 use WP_REST_Server;
14 use WP_Error;
15
16 /**
17 * @method static Integration get_instance($args = null)
18 */
19 class Integration {
20 /**
21 * Instance of NotificationX
22 *
23 * @var NotificationX
24 */
25 use GetInstance;
26
27 public $namespace;
28 public $rest_base;
29
30 /**
31 * Grace window during which the legacy md5(home_url()) key is still accepted
32 * after upgrade, so existing Zapier zaps keep firing while customers rotate.
33 */
34 const LEGACY_KEY_GRACE_DAYS = 14;
35
36 const OPT_API_KEY = 'nx_integration_api_key';
37 const OPT_GRACE_STARTED_AT = 'nx_integration_api_key_grace_started_at';
38 const OPT_LEGACY_KEY_LAST_USED = 'nx_integration_legacy_key_last_used_at';
39 const OPT_SEEDED_VERSION = 'nx_integration_key_seeded_version';
40
41 /**
42 * Constructor.
43 *
44 * @since 4.7.0
45 *
46 * @param string $post_type Post type.
47 */
48 public function __construct() {
49 $this->namespace = 'notificationx/v1';
50 $this->rest_base = 'notification';
51 add_action('rest_api_init', [$this, 'register_routes']);
52 add_action('admin_notices', [$this, 'legacy_api_key_notice']);
53 // Runs once per install/upgrade to seed the API key and, only when
54 // needed, open the legacy-key grace window. Gated by a version marker
55 // so it never retriggers on the same version.
56 add_action('plugins_loaded', [__CLASS__, 'maybe_seed_from_upgrade'], 20);
57 }
58
59 /**
60 * Registers the routes for the objects of the controller.
61 *
62 * @since 4.7.0
63 *
64 * @see register_rest_route()
65 */
66 public function register_routes() {
67 // Settings Integration
68 register_rest_route( $this->namespace, '/api-connect', array(
69 'methods' => WP_REST_Server::EDITABLE,
70 'callback' => array( $this, 'api_connect' ),
71 'permission_callback' => array($this, 'settings_permission'),
72 ));
73
74 // calls from integration provider.
75 register_rest_route(
76 $this->namespace,
77 '/' . $this->rest_base . '/(?P<id>[\d]+)',
78 array(
79 array(
80 'methods' => WP_REST_Server::READABLE,
81 'callback' => array($this, 'get_response'),
82 'permission_callback' => '__return_true',
83 'args' => array(
84 'id' => array(
85 'required' => true,
86 'description' => __('Unique identifier for the object.', 'notificationx'),
87 'type' => 'integer',
88 ),
89 'api_key' => array(
90 'required' => true,
91 'description' => __('Unique identifier for the site.', 'notificationx'),
92 'type' => 'string',
93 ),
94 ),
95 ),
96 array(
97 'methods' => WP_REST_Server::CREATABLE,
98 'callback' => array($this, 'save_response'),
99 'permission_callback' => '__return_true',
100 'args' => array(
101 'id' => array(
102 'required' => true,
103 'description' => __('Unique identifier for the object.', 'notificationx'),
104 'type' => 'integer',
105 ),
106 'api_key' => array(
107 'required' => true,
108 'description' => __('Unique identifier for the site.', 'notificationx'),
109 'type' => 'string',
110 ),
111 ),
112 ),
113 )
114 );
115 // OLD Fallback for Zapier
116 register_rest_route(
117 "notificationx",
118 '/' . $this->rest_base . '/(?P<id>[\d]+)',
119 array(
120 array(
121 'methods' => WP_REST_Server::READABLE,
122 'callback' => array($this, 'get_response'),
123 'permission_callback' => '__return_true',
124 'args' => array(
125 'id' => array(
126 'required' => true,
127 'description' => __('Unique identifier for the object.', 'notificationx'),
128 'type' => 'integer',
129 ),
130 'api_key' => array(
131 'required' => true,
132 'description' => __('Unique identifier for the site.', 'notificationx'),
133 'type' => 'string',
134 ),
135 ),
136 ),
137 array(
138 'methods' => WP_REST_Server::CREATABLE,
139 'callback' => array($this, 'save_response'),
140 'permission_callback' => '__return_true',
141 'args' => array(
142 'id' => array(
143 'required' => true,
144 'description' => __('Unique identifier for the object.', 'notificationx'),
145 'type' => 'integer',
146 ),
147 'api_key' => array(
148 'required' => true,
149 'description' => __('Unique identifier for the site.', 'notificationx'),
150 'type' => 'string',
151 ),
152 ),
153 ),
154 )
155 );
156 }
157
158 /**
159 * Returns the site's integration API key, generating and persisting one if it doesn't exist yet.
160 * Key generation has no side effects on the legacy-key grace window — that is opened only by
161 * {@see self::maybe_seed_from_upgrade()} at plugin bootstrap, so an unauthenticated attacker
162 * probing the endpoint can never lazy-open a grace window.
163 */
164 public static function get_api_key() {
165 $key = get_option( self::OPT_API_KEY );
166 if ( empty( $key ) ) {
167 $key = wp_generate_password( 32, false );
168 update_option( self::OPT_API_KEY, $key, false );
169 }
170 return $key;
171 }
172
173 /**
174 * Runs once per plugin version at bootstrap. Ensures the API key exists and opens the
175 * legacy-key grace window ONLY when the site actually had legacy integrations configured
176 * before the upgrade — never on a fresh install and never on customer sites that never
177 * used Zapier/IFTTT. Gated by a version marker so it does not retrigger.
178 */
179 public static function maybe_seed_from_upgrade() {
180 $seeded = get_option( self::OPT_SEEDED_VERSION );
181 if ( $seeded === NOTIFICATIONX_VERSION ) {
182 return;
183 }
184 update_option( self::OPT_SEEDED_VERSION, NOTIFICATIONX_VERSION, false );
185
186 // Ensure the new key exists (idempotent — no grace side effect).
187 self::get_api_key();
188
189 // Open the grace window only for sites that had legacy integrations.
190 if ( ! get_option( self::OPT_GRACE_STARTED_AT ) && self::has_legacy_integrations() ) {
191 update_option( self::OPT_GRACE_STARTED_AT, time(), false );
192 }
193 }
194
195 /**
196 * Detects whether the site has (or had) legacy Zapier/IFTTT integrations that would have
197 * been configured with the legacy md5(home_url()) key. Used to decide whether the upgrade
198 * seeder should open a grace window.
199 */
200 protected static function has_legacy_integrations(): bool {
201 global $wpdb;
202 $table = $wpdb->prefix . 'nx_posts';
203 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- False positive: the query is prepared via $this->wpdb->prepare(), which this sniff does not recognise, and only $wpdb->prefix table names are interpolated. Audited 2026-07-16.
204 $count = $wpdb->get_var(
205 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- False positive: the query is prepared via $this->wpdb->prepare(), which this sniff does not recognise, and only $wpdb->prefix table names are interpolated. Audited 2026-07-16.
206 "SELECT COUNT(*) FROM {$table}
207 WHERE source LIKE 'zapier%%'
208 OR source LIKE 'ifttt%%'"
209 );
210 return (int) $count > 0;
211 }
212
213 /**
214 * Unix timestamp at which the legacy md5(home_url()) key stops being accepted.
215 */
216 public static function legacy_key_grace_ends_at(): int {
217 $started = (int) get_option( self::OPT_GRACE_STARTED_AT );
218 if ( ! $started ) {
219 return 0;
220 }
221 return $started + ( self::LEGACY_KEY_GRACE_DAYS * DAY_IN_SECONDS );
222 }
223
224 /**
225 * Validates an incoming API key.
226 * The new random key is always accepted. The legacy md5(home_url()) key is accepted only
227 * during the post-upgrade grace window; we record every accepted legacy use so the admin
228 * notice can prompt the customer to rotate. After the grace window closes, the legacy key
229 * is rejected. Wrong keys never write to the DB — so a probing attacker can never open a
230 * grace window or flip the admin notice on.
231 */
232 public static function is_valid_api_key( string $api_key ): bool {
233 $stored = (string) get_option( self::OPT_API_KEY );
234 if ( $stored !== '' && hash_equals( $stored, $api_key ) ) {
235 return true;
236 }
237 $is_legacy = hash_equals( md5( home_url( '', 'http' ) ), $api_key )
238 || hash_equals( md5( home_url( '', 'https' ) ), $api_key );
239 if ( ! $is_legacy ) {
240 return false;
241 }
242 $grace_ends_at = self::legacy_key_grace_ends_at();
243 if ( $grace_ends_at === 0 || time() >= $grace_ends_at ) {
244 return false;
245 }
246 update_option( self::OPT_LEGACY_KEY_LAST_USED, time(), false );
247 return true;
248 }
249
250 /**
251 * Persistent dashboard notice — surfaced whenever the legacy key has been used recently
252 * so the customer can rotate before (or after) the grace window closes.
253 */
254 public function legacy_api_key_notice() {
255 if ( ! current_user_can( 'edit_notificationx_settings' ) ) {
256 return;
257 }
258 $last_used = (int) get_option( self::OPT_LEGACY_KEY_LAST_USED );
259 if ( ! $last_used ) {
260 return;
261 }
262 $grace_ends_at = self::legacy_key_grace_ends_at();
263 $settings_url = admin_url( 'admin.php?page=nx-settings' );
264 if ( $grace_ends_at > 0 && time() < $grace_ends_at ) {
265 $deadline = wp_date( get_option( 'date_format' ), $grace_ends_at );
266 $message = sprintf(
267 /* translators: %s: rotation deadline date. */
268 __( 'A Zapier (or other webhook) integration is still calling NotificationX with the legacy API key. Rotate it to the new key before %s — after that, requests using the old key will be rejected.', 'notificationx' ),
269 '<strong>' . esc_html( $deadline ) . '</strong>'
270 );
271 $class = 'notice notice-warning';
272 } else {
273 $message = __( 'A Zapier (or other webhook) integration is still calling NotificationX with the legacy API key. Those calls are now being rejected — update the integration with the new key to restore it.', 'notificationx' );
274 $class = 'notice notice-error';
275 }
276 printf(
277 '<div class="%1$s"><p>%2$s <a href="%3$s">%4$s</a></p></div>',
278 esc_attr( $class ),
279 wp_kses_post( $message ),
280 esc_url( $settings_url ),
281 esc_html__( 'Get the new key', 'notificationx' )
282 );
283 }
284
285 public function get_response( \WP_REST_Request $request ){
286 $id = $request['id'];
287 $api_key = $request['api_key'];
288 $error = [];
289
290 if( self::is_valid_api_key( (string) $api_key ) ) {
291 $notificationx = PostType::get_instance()->get_post( $id );
292 if( $notificationx ) {
293 return wp_send_json( true );
294 }
295 /* translators: %s: notification ID */
296 $error['message'] = sprintf( __( 'There is no notification created with this id: %s', 'notificationx' ), $id );
297 return wp_send_json_error( $error, 401 );
298 } else {
299 $error['message'] = __( 'Error: API Key Invalid!', 'notificationx' );
300 return wp_send_json_error( $error, 401 );
301 }
302 }
303
304 /**
305 * Undocumented function
306 *
307 * @param \WP_REST_Request $request
308 * @return void
309 */
310 public function save_response( \WP_REST_Request $request ){
311 $response_data = array(
312 'data' => '',
313 'error' => false
314 );
315
316 if ( ! isset( $request['api_key'] ) ) {
317 $response_data['error'] = __('Error: You should provide an API key.', 'notificationx');
318 } else {
319 if ( ! self::is_valid_api_key( (string) $request['api_key'] ) ) {
320 $response_data['error'] = __('Error: Invalid API key.', 'notificationx');
321 }
322 }
323
324 if ( ! $response_data['error'] ) {
325 $response_data['data'] = $request->get_params();
326 if ( isset( $response_data['data']['api_key'] ) ) {
327 unset( $response_data['data']['api_key'] );
328 }
329 array_walk_recursive( $response_data['data'], function( &$val ) {
330 $val = sanitize_text_field( (string) $val );
331 } );
332 if (isset($response_data['data']['id'])){
333 $post = PostType::get_instance()->get_post($response_data['data']['id']);
334 if($post['source']){
335 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
336 do_action( "nx_api_response_success_{$post['source']}", $response_data['data'] );
337 }
338 }
339 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
340 do_action( 'nx_api_response_success', $response_data['data'] );
341 }
342
343 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
344 return apply_filters( 'nx_api_response', $response_data );
345 }
346
347 /**
348 * Undocumented function
349 *
350 * @param \WP_REST_Request $request
351 * @return
352 */
353 public function api_connect( \WP_REST_Request $request ){
354 $params = $request->get_params();
355 $source = !empty($params['source']) ? $params['source'] : '';
356 /**
357 * @var Extension
358 */
359 $ext = ExtensionFactory::get_instance()->get($source);
360 if($ext && method_exists($ext, 'connect')){
361 return $ext->connect($params);
362 }
363 else{
364 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
365 $result = apply_filters("nx_api_connect_$source", null, $params);
366 if($result){
367 return $result;
368 }
369 }
370 return REST::get_instance()->error();
371 }
372
373 public function settings_permission( $request ) {
374 return current_user_can('edit_notificationx_settings');
375 }
376 }
377