namespace = 'notificationx/v1'; $this->rest_base = 'notification'; add_action('rest_api_init', [$this, 'register_routes']); add_action('admin_notices', [$this, 'legacy_api_key_notice']); // Runs once per install/upgrade to seed the API key and, only when // needed, open the legacy-key grace window. Gated by a version marker // so it never retriggers on the same version. add_action('plugins_loaded', [__CLASS__, 'maybe_seed_from_upgrade'], 20); } /** * Registers the routes for the objects of the controller. * * @since 4.7.0 * * @see register_rest_route() */ public function register_routes() { // Settings Integration register_rest_route( $this->namespace, '/api-connect', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'api_connect' ), 'permission_callback' => array($this, 'settings_permission'), )); // calls from integration provider. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array($this, 'get_response'), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'required' => true, 'description' => __('Unique identifier for the object.', 'notificationx'), 'type' => 'integer', ), 'api_key' => array( 'required' => true, 'description' => __('Unique identifier for the site.', 'notificationx'), 'type' => 'string', ), ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array($this, 'save_response'), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'required' => true, 'description' => __('Unique identifier for the object.', 'notificationx'), 'type' => 'integer', ), 'api_key' => array( 'required' => true, 'description' => __('Unique identifier for the site.', 'notificationx'), 'type' => 'string', ), ), ), ) ); // OLD Fallback for Zapier register_rest_route( "notificationx", '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array($this, 'get_response'), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'required' => true, 'description' => __('Unique identifier for the object.', 'notificationx'), 'type' => 'integer', ), 'api_key' => array( 'required' => true, 'description' => __('Unique identifier for the site.', 'notificationx'), 'type' => 'string', ), ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array($this, 'save_response'), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'required' => true, 'description' => __('Unique identifier for the object.', 'notificationx'), 'type' => 'integer', ), 'api_key' => array( 'required' => true, 'description' => __('Unique identifier for the site.', 'notificationx'), 'type' => 'string', ), ), ), ) ); } /** * Returns the site's integration API key, generating and persisting one if it doesn't exist yet. * Key generation has no side effects on the legacy-key grace window — that is opened only by * {@see self::maybe_seed_from_upgrade()} at plugin bootstrap, so an unauthenticated attacker * probing the endpoint can never lazy-open a grace window. */ public static function get_api_key() { $key = get_option( self::OPT_API_KEY ); if ( empty( $key ) ) { $key = wp_generate_password( 32, false ); update_option( self::OPT_API_KEY, $key, false ); } return $key; } /** * Runs once per plugin version at bootstrap. Ensures the API key exists and opens the * legacy-key grace window ONLY when the site actually had legacy integrations configured * before the upgrade — never on a fresh install and never on customer sites that never * used Zapier/IFTTT. Gated by a version marker so it does not retrigger. */ public static function maybe_seed_from_upgrade() { $seeded = get_option( self::OPT_SEEDED_VERSION ); if ( $seeded === NOTIFICATIONX_VERSION ) { return; } update_option( self::OPT_SEEDED_VERSION, NOTIFICATIONX_VERSION, false ); // Ensure the new key exists (idempotent — no grace side effect). self::get_api_key(); // Open the grace window only for sites that had legacy integrations. if ( ! get_option( self::OPT_GRACE_STARTED_AT ) && self::has_legacy_integrations() ) { update_option( self::OPT_GRACE_STARTED_AT, time(), false ); } } /** * Detects whether the site has (or had) legacy Zapier/IFTTT integrations that would have * been configured with the legacy md5(home_url()) key. Used to decide whether the upgrade * seeder should open a grace window. */ protected static function has_legacy_integrations(): bool { global $wpdb; $table = $wpdb->prefix . 'nx_posts'; // 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. $count = $wpdb->get_var( // 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. "SELECT COUNT(*) FROM {$table} WHERE source LIKE 'zapier%%' OR source LIKE 'ifttt%%'" ); return (int) $count > 0; } /** * Unix timestamp at which the legacy md5(home_url()) key stops being accepted. */ public static function legacy_key_grace_ends_at(): int { $started = (int) get_option( self::OPT_GRACE_STARTED_AT ); if ( ! $started ) { return 0; } return $started + ( self::LEGACY_KEY_GRACE_DAYS * DAY_IN_SECONDS ); } /** * Validates an incoming API key. * The new random key is always accepted. The legacy md5(home_url()) key is accepted only * during the post-upgrade grace window; we record every accepted legacy use so the admin * notice can prompt the customer to rotate. After the grace window closes, the legacy key * is rejected. Wrong keys never write to the DB — so a probing attacker can never open a * grace window or flip the admin notice on. */ public static function is_valid_api_key( string $api_key ): bool { $stored = (string) get_option( self::OPT_API_KEY ); if ( $stored !== '' && hash_equals( $stored, $api_key ) ) { return true; } $is_legacy = hash_equals( md5( home_url( '', 'http' ) ), $api_key ) || hash_equals( md5( home_url( '', 'https' ) ), $api_key ); if ( ! $is_legacy ) { return false; } $grace_ends_at = self::legacy_key_grace_ends_at(); if ( $grace_ends_at === 0 || time() >= $grace_ends_at ) { return false; } update_option( self::OPT_LEGACY_KEY_LAST_USED, time(), false ); return true; } /** * Persistent dashboard notice — surfaced whenever the legacy key has been used recently * so the customer can rotate before (or after) the grace window closes. */ public function legacy_api_key_notice() { if ( ! current_user_can( 'edit_notificationx_settings' ) ) { return; } $last_used = (int) get_option( self::OPT_LEGACY_KEY_LAST_USED ); if ( ! $last_used ) { return; } $grace_ends_at = self::legacy_key_grace_ends_at(); $settings_url = admin_url( 'admin.php?page=nx-settings' ); if ( $grace_ends_at > 0 && time() < $grace_ends_at ) { $deadline = wp_date( get_option( 'date_format' ), $grace_ends_at ); $message = sprintf( /* translators: %s: rotation deadline date. */ __( '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' ), '' . esc_html( $deadline ) . '' ); $class = 'notice notice-warning'; } else { $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' ); $class = 'notice notice-error'; } printf( '

%2$s %4$s

', esc_attr( $class ), wp_kses_post( $message ), esc_url( $settings_url ), esc_html__( 'Get the new key', 'notificationx' ) ); } public function get_response( \WP_REST_Request $request ){ $id = $request['id']; $api_key = $request['api_key']; $error = []; if( self::is_valid_api_key( (string) $api_key ) ) { $notificationx = PostType::get_instance()->get_post( $id ); if( $notificationx ) { return wp_send_json( true ); } /* translators: %s: notification ID */ $error['message'] = sprintf( __( 'There is no notification created with this id: %s', 'notificationx' ), $id ); return wp_send_json_error( $error, 401 ); } else { $error['message'] = __( 'Error: API Key Invalid!', 'notificationx' ); return wp_send_json_error( $error, 401 ); } } /** * Undocumented function * * @param \WP_REST_Request $request * @return void */ public function save_response( \WP_REST_Request $request ){ $response_data = array( 'data' => '', 'error' => false ); if ( ! isset( $request['api_key'] ) ) { $response_data['error'] = __('Error: You should provide an API key.', 'notificationx'); } else { if ( ! self::is_valid_api_key( (string) $request['api_key'] ) ) { $response_data['error'] = __('Error: Invalid API key.', 'notificationx'); } } if ( ! $response_data['error'] ) { $response_data['data'] = $request->get_params(); if ( isset( $response_data['data']['api_key'] ) ) { unset( $response_data['data']['api_key'] ); } array_walk_recursive( $response_data['data'], function( &$val ) { $val = sanitize_text_field( (string) $val ); } ); if (isset($response_data['data']['id'])){ $post = PostType::get_instance()->get_post($response_data['data']['id']); if($post['source']){ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. do_action( "nx_api_response_success_{$post['source']}", $response_data['data'] ); } } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. do_action( 'nx_api_response_success', $response_data['data'] ); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. return apply_filters( 'nx_api_response', $response_data ); } /** * Undocumented function * * @param \WP_REST_Request $request * @return */ public function api_connect( \WP_REST_Request $request ){ $params = $request->get_params(); $source = !empty($params['source']) ? $params['source'] : ''; /** * @var Extension */ $ext = ExtensionFactory::get_instance()->get($source); if($ext && method_exists($ext, 'connect')){ return $ext->connect($params); } else{ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. $result = apply_filters("nx_api_connect_$source", null, $params); if($result){ return $result; } } return REST::get_instance()->error(); } public function settings_permission( $request ) { return current_user_can('edit_notificationx_settings'); } }