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/Core/Rest/Integration.php +376 -0 0.2.5.6trunk View file →
@@ -1,0 +1,376 @@
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 +}