*/ private $hooks = array(); /** * Register hooks. * * @return void */ public function boot() { add_action( 'admin_menu', array( $this, 'register_page' ) ); add_action( 'admin_init', array( $this, 'maybe_redirect_legacy_url' ) ); add_action( 'admin_init', array( $this, 'maybe_complete_connect' ) ); add_filter( 'plugin_action_links_' . plugin_basename( SEND4_PLUGIN_FILE ), array( $this, 'action_links' ) ); add_filter( 'admin_body_class', array( $this, 'body_class' ) ); add_action( 'admin_post_send4_connect', array( $this, 'handle_connect' ) ); add_action( 'admin_post_send4_start_connect', array( $this, 'handle_start_connect' ) ); add_action( 'admin_post_send4_disconnect', array( $this, 'handle_disconnect' ) ); add_action( 'admin_post_send4_test', array( $this, 'handle_test' ) ); add_action( 'admin_post_send4_save_mapping', array( $this, 'handle_save_mapping' ) ); add_action( 'admin_post_send4_disable_form', array( $this, 'handle_disable_form' ) ); add_action( 'admin_post_send4_sync_forms', array( $this, 'handle_sync_forms' ) ); add_action( 'admin_post_send4_run_queue', array( $this, 'handle_run_queue' ) ); add_action( 'admin_post_send4_requeue', array( $this, 'handle_requeue' ) ); add_action( 'admin_post_send4_clear_log', array( $this, 'handle_clear_log' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); } /** * Add the menu and its screens. * * @return void */ public function register_page() { // The hook suffixes are kept rather than rebuilt from the slugs later: // WordPress names a submenu's screen after sanitize_title() of the // parent's *translated* menu title, so "send4_page_send4-forms" only // holds while the title is untranslated. A site in another language // would lose the stylesheet on every screen but the dashboard. $this->hooks = array(); $this->hooks['dashboard'] = (string) add_menu_page( __( 'Send4', 'send4' ), __( 'Send4', 'send4' ), self::CAPABILITY, self::PAGE_SLUG, array( $this, 'render_dashboard' ), 'dashicons-format-chat', 26 ); add_submenu_page( self::PAGE_SLUG, __( 'Send4 Dashboard', 'send4' ), __( 'Dashboard', 'send4' ), self::CAPABILITY, self::PAGE_SLUG, array( $this, 'render_dashboard' ) ); $this->hooks['forms'] = (string) add_submenu_page( self::PAGE_SLUG, __( 'Forms & Triggers', 'send4' ), __( 'Forms & Triggers', 'send4' ), self::CAPABILITY, self::FORMS_SLUG, array( $this, 'render_forms' ) ); $this->hooks['connection'] = (string) add_submenu_page( self::PAGE_SLUG, __( 'Connection', 'send4' ), __( 'Connection', 'send4' ), self::CAPABILITY, self::CONNECTION_SLUG, array( $this, 'render_connection' ) ); // add_submenu_page() answers false for a user without the capability. $this->hooks = array_filter( $this->hooks ); } /** * Answer for options-general.php?page=send4, where this plugin used to * live. * * A connection started before the upgrade told Send4 to return the browser * to that URL and has 24 hours to do so, and bookmarks outlive releases. * * Done on admin_init rather than by registering the old slug as a hidden * page: the slug is now a top-level menu, so WordPress resolves a * Settings-parented page with the same slug to `toplevel_page_send4` — * the dashboard's own hook — and the redirect would fire on the dashboard * instead. admin_init runs before wp-admin dispatches the page, so this * lands earlier and cannot collide with anything. * * @return void */ public function maybe_redirect_legacy_url() { global $pagenow; // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( 'options-general.php' !== $pagenow || ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) { return; } if ( ! current_user_can( self::CAPABILITY ) ) { return; } $args = array(); foreach ( array( 'send4_code', 'send4_connect_id' ) as $key ) { if ( ! empty( $_GET[ $key ] ) ) { // Encoded here because add_query_arg() does not encode what it // is given: unencoded, a code carrying an ampersand would be // split into extra query arguments — one of which could be // `page`, and the last `page` wins. $args[ $key ] = rawurlencode( sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) ); } } // phpcs:enable WordPress.Security.NonceVerification.Recommended wp_safe_redirect( $this->page_url( $args, self::CONNECTION_SLUG ) ); exit; } /** * Redeem the one-time code Send4 sends the browser back with. * * It arrives as a GET on the Connection screen rather than through * admin-post.php, because Send4 is redirecting a browser, not posting a * form. There is no WP nonce to check and none is needed: the exchange also * requires the registration secret, which never left this site, so a forged * link carrying somebody else's code cannot produce a credential. * * On admin_init and not in render_connection(), because every path out of * here ends in a redirect and wp-admin prints admin-header.php before it * calls a page's render callback. Redirecting from the renderer means * wp_redirect() cannot send Location — "headers already sent" — and the * exit that follows truncates the page, so the admin gets a blank screen * whether the connection succeeded or failed. admin_init runs before a * byte of output. * * @return void */ public function maybe_complete_connect() { global $pagenow; // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( 'admin.php' !== $pagenow || ! isset( $_GET['page'] ) || self::CONNECTION_SLUG !== $_GET['page'] ) { return; } if ( empty( $_GET['send4_code'] ) || empty( $_GET['send4_connect_id'] ) ) { return; } // Not wp_die(): someone without the capability cannot reach the screen // anyway, and WordPress says so in its own words a moment later. if ( ! current_user_can( self::CAPABILITY ) ) { return; } $this->complete_connect( sanitize_text_field( wp_unslash( $_GET['send4_code'] ) ), sanitize_text_field( wp_unslash( $_GET['send4_connect_id'] ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended } /** * "Settings" link on the plugins screen. * * @param array $links Existing links. * @return array */ public function action_links( $links ) { $links[] = '' . esc_html__( 'Settings', 'send4' ) . ''; return $links; } /** * Stylesheet for the Send4 screens. * * @param string $hook_suffix Current admin page. * @return void */ public function enqueue( $hook_suffix ) { if ( ! $this->is_send4_screen( $hook_suffix ) ) { return; } wp_enqueue_style( 'send4-admin', Send4_Assets::url( 'admin/css/admin.css' ), array(), SEND4_VERSION ); // Names the default-country options in the admin's own language. The // select works without it, showing country codes. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $this->hooks['forms'] ) && $this->hooks['forms'] === $hook_suffix && ! empty( $_GET['form'] ) ) { wp_enqueue_script( 'send4-phone-country', Send4_Assets::url( 'admin/js/phone-country.js' ), array(), SEND4_VERSION, true ); } } /** * Mark our own screens so the stylesheet can restyle the content area * without reaching into any other plugin's page. * * @param string $classes Space separated body classes. * @return string */ public function body_class( $classes ) { $screen = get_current_screen(); if ( null !== $screen && $this->is_send4_screen( $screen->id ) ) { $classes .= ' send4-screen'; } return $classes; } /** * Is this hook suffix / screen id one of ours? * * @param string $hook_suffix Hook suffix or screen id. * @return bool */ private function is_send4_screen( $hook_suffix ) { return '' !== (string) $hook_suffix && in_array( (string) $hook_suffix, $this->hooks, true ); } /** * URL of one of our screens. * * @param array $args Extra query args. * @param string $slug Page slug, defaulting to the dashboard. * @return string */ public function page_url( array $args = array(), $slug = self::PAGE_SLUG ) { return add_query_arg( array_merge( array( 'page' => $slug ), $args ), admin_url( 'admin.php' ) ); } /** * The overview screen. * * @return void */ public function render_dashboard() { $this->guard(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $range = isset( $_GET['range'] ) ? sanitize_text_field( wp_unslash( $_GET['range'] ) ) : '30d'; $admin = $this; $data = Send4_Admin_Dashboard::build( $this, Send4_Stats::valid_range( $range ) ); require SEND4_PLUGIN_DIR . 'admin/views/dashboard.php'; } /** * The form list, or one form's field mapping. * * @return void */ public function render_forms() { $this->guard(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $form_key = isset( $_GET['form'] ) ? sanitize_text_field( wp_unslash( $_GET['form'] ) ) : ''; if ( '' !== $form_key ) { $this->render_mapping( $form_key ); return; } $handshake = Send4_Settings::has_credentials() ? $this->handshake( false ) : null; $forms = Send4_Adapters::detect_forms(); $adapters = Send4_Adapters::all(); $admin = $this; require SEND4_PLUGIN_DIR . 'admin/views/forms.php'; } /** * The connection screen. * * @return void */ public function render_connection() { $this->guard(); $connection = Send4_Settings::connection(); $handshake = Send4_Settings::has_credentials() ? $this->handshake( false ) : null; $admin = $this; require SEND4_PLUGIN_DIR . 'admin/views/connection.php'; } /** * Render the per-form mapping screen. * * @param string $form_key Form key. * @return void */ private function render_mapping( $form_key ) { $descriptor = $this->find_form( $form_key ); if ( null === $descriptor ) { echo '

' . esc_html__( 'Send4', 'send4' ) . '

' . esc_html__( 'That form no longer exists.', 'send4' ) . '

' . esc_html__( 'Back to forms', 'send4' ) . '

'; return; } $form = Send4_Settings::form( $form_key ); // Only the app bar's pill reads this, and the admin almost always // arrives from the form list, which has just checked. Not worth a // network round trip before the page can draw. $handshake = Send4_Settings::has_credentials() ? self::cached_handshake() : null; $admin = $this; require SEND4_PLUGIN_DIR . 'admin/views/mapping.php'; } /** * Every screen starts by proving the caller may be here. * * @return void */ private function guard() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to configure Send4.', 'send4' ) ); } } /** * Find a detected form by key. * * @param string $form_key Form key. * @return array|null */ public function find_form( $form_key ) { return Send4_Adapters::find_form( $form_key ); } /** * Handshake result, cached briefly so opening a screen is cheap. * * Failures are cached too, for less time. Without that, every screen asked * again during an outage and waited out the timeout before it could draw — * and a page drawn from the cache is only fast if the cache ever holds * something. * * An unforced call is a screen being drawn, so it waits less than an * explicit Test connection does. * * @param bool $force Bypass the cache. * @return array|WP_Error */ public function handshake( $force = false ) { if ( ! $force ) { $cached = self::cached_handshake(); if ( null !== $cached ) { return $cached; } } $client = new Send4_Api_Client( $force ? Send4_Api_Client::TIMEOUT_INTERACTIVE : Send4_Api_Client::TIMEOUT_SCREEN ); $result = $client->handshake(); self::remember_handshake( $result ); return $result; } /** * The last handshake result, if one is still fresh, without asking again. * * @return array|WP_Error|null Null when nothing is cached. */ public static function cached_handshake() { $cached = get_transient( self::HANDSHAKE_CACHE ); if ( ! is_array( $cached ) ) { return null; } if ( ! isset( $cached[ self::HANDSHAKE_FAILURE_KEY ] ) ) { return $cached; } $failure = $cached[ self::HANDSHAKE_FAILURE_KEY ]; if ( ! is_array( $failure ) || empty( $failure['code'] ) ) { return null; } return new WP_Error( (string) $failure['code'], isset( $failure['message'] ) ? (string) $failure['message'] : '', isset( $failure['data'] ) ? $failure['data'] : '' ); } /** * Cache a handshake result for the screens. * * One key for both outcomes, so a failed Test connection replaces a cached * success rather than leaving the screens contradicting the notice. * * @param array|WP_Error $result Handshake result. * @return void */ private static function remember_handshake( $result ) { if ( ! is_wp_error( $result ) ) { set_transient( self::HANDSHAKE_CACHE, $result, self::HANDSHAKE_CACHE_S ); Send4_Minimum_Version::remember( $result ); return; } // Stored as plain values rather than the object, which a transient // would have to unserialize back into a class. set_transient( self::HANDSHAKE_CACHE, array( self::HANDSHAKE_FAILURE_KEY => array( 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), 'data' => $result->get_error_data(), ), ), self::HANDSHAKE_FAILURE_S ); } /** * Which of the connection states a screen should describe. * * - disconnected: no credentials; nothing is sent. * - unreachable: credentials, but Send4 did not answer (or answered with * a server error); submissions are queued and retried. * - rejected: Send4 answered and refused the credentials; submissions * are refused too, not queued, so the copy has to differ. * - unchecked: credentials, and no handshake result to hand. * - connected: the handshake answered. * * "Rejected" mirrors what Send4_Api_Client treats as a permanent failure * for a submission, so this screen and the delivery path agree about * whether a lead is waiting or lost. * * @param array|WP_Error|null $handshake Handshake result. * @return string */ public static function connection_state( $handshake ) { if ( ! Send4_Settings::has_credentials() ) { return 'disconnected'; } if ( is_array( $handshake ) ) { return 'connected'; } if ( ! is_wp_error( $handshake ) ) { return 'unchecked'; } $code = $handshake->get_error_code(); $data = $handshake->get_error_data(); if ( in_array( $code, array( 'send4_missing_ability', 'send4_bad_destination', 'send4_not_connected' ), true ) ) { return 'rejected'; } $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; if ( 'send4_token_rejected' === $code && in_array( $status, array( 400, 401 ), true ) ) { return 'rejected'; } return 'unreachable'; } /** * What a failing connection means for the site's submissions, in words. * * @param string $state A connection_state() value. * @return string Empty for states that are not failures. */ public static function outage_message( $state ) { if ( 'unreachable' === $state ) { return __( 'Send4 could not be reached just now. Form submissions are being queued and will be sent automatically once it answers again.', 'send4' ); } if ( 'rejected' === $state ) { return __( 'Send4 refused this site\'s connection. Form submissions are not being delivered until it is fixed: test the connection again, or reconnect.', 'send4' ); } return ''; } /** * Drop the cached handshake, whatever it says. * * @return void */ public static function forget_handshake() { delete_transient( self::HANDSHAKE_CACHE ); } /** * Which flows are bound to a form key, from the cached handshake. * * @param array|WP_Error|null $handshake Handshake result. * @param string $form_key Form key. * @return array */ public function flows_for( $handshake, $form_key ) { if ( ! is_array( $handshake ) || empty( $handshake['flows'] ) ) { return array(); } $exact = array(); $catchall = array(); foreach ( $handshake['flows'] as $flow ) { $bound = isset( $flow['form_key'] ) ? $flow['form_key'] : null; if ( null === $bound || '' === $bound ) { $catchall[] = $flow; continue; } if ( $bound === $form_key ) { $exact[] = $flow; } } return empty( $exact ) ? $catchall : $exact; } /** * Save credentials and immediately prove they work. * * @return void */ public function handle_connect() { $this->require_capability(); check_admin_referer( 'send4_connect' ); $client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : ''; $client_secret = isset( $_POST['client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['client_secret'] ) ) : ''; $existing = Send4_Settings::connection(); // sanitize_text_field() leaves a real secret exactly as it was: only // letters, digits, "_" and "-", which it never touches. Anything else // was not a Send4 secret to begin with, so it is refused, never sent. if ( '' !== $client_secret && ! Send4_Settings::is_client_secret( $client_secret ) ) { $this->redirect_with_notice( 'error', __( 'That is not a Send4 client secret. Copy it again from your API key in Send4.', 'send4' ), array(), self::CONNECTION_SLUG ); } if ( '' === $client_secret ) { $client_secret = $existing['client_secret']; } if ( '' === $client_id || '' === $client_secret ) { $this->redirect_with_notice( 'error', __( 'Both the client ID and the client secret are required.', 'send4' ), array(), self::CONNECTION_SLUG ); } $this->confirm_connection( $client_id, $client_secret ); } /** * Prove a credential works, and only then make it this site's. * * The one routine behind every way in — pasting a key, coming back from * the Connect button, and `wp send4 connect` — because "connected" has to * mean the same thing each way: the handshake answered, so the credential * is real AND carries the ability. * * The candidate is tried in memory first. Saving it before the handshake, * as this used to, meant a mistyped secret replaced a working one, and * every submission after that was refused and dead-lettered until somebody * noticed. On failure nothing is written at all. * * That includes where the API is. `wp send4 connect --url-base` used to * save the new destination before trying the pair, so a refused pair left * the site's working credentials pointed at the wrong environment. The * destination is now tried and saved with the pair it belongs to. * * @param string $client_id Candidate client ID. * @param string $client_secret Candidate client secret. * @param string $base_url Candidate API root, '' to keep the configured one. * The caller has checked it is an allowed address * and that no constant pins the destination. * @return array|WP_Error The handshake on success. */ public static function establish_connection( $client_id, $client_secret, $base_url = '' ) { $base_url = (string) $base_url; $result = Send4_Api_Client::with_credentials( $client_id, $client_secret, Send4_Api_Client::TIMEOUT_INTERACTIVE, $base_url )->handshake(); if ( is_wp_error( $result ) ) { return $result; } // Before the credentials go, exactly as on Disconnect: a row queued // before rows carried a client id belongs to the connection this one // replaces. Every way of connecting comes through here, and all of them // can be used while the site is still connected to another account — // left unowned, that row would be delivered to the new one. if ( Send4_Queue::table_exists() ) { Send4_Queue::adopt_unowned( Send4_Queue::current_client_id() ); } if ( '' !== $base_url ) { update_option( Send4_Settings::BASE_URL_OPTION, untrailingslashit( $base_url ), false ); } Send4_Settings::update_connection( array( 'client_id' => (string) $client_id, 'client_secret' => (string) $client_secret, 'client_name' => isset( $result['client']['name'] ) ? (string) $result['client']['name'] : '', 'connected_at' => gmdate( 'Y-m-d H:i:s' ), ) ); // The cached token belongs to whatever credential was stored before; // sending it on behalf of this one would be wrong either way. Send4_Api_Client::forget_token(); self::remember_handshake( $result ); // Tell the workspace what forms this site has, so its flow builder can // offer them straight away rather than after the first submission // (#0687). Forgotten and forced, because a reconnect may be a different // workspace that has never been told anything about this site. A // failure here does not undo the connection — the daily sweep retries. Send4_Forms_Sync::forget(); Send4_Forms_Sync::maybe_push( true ); return $result; } /** * Connect with a candidate credential and say how it went. * * Always redirects, back to the Connection screen the admin acted on. * * @param string $client_id Candidate client ID. * @param string $client_secret Candidate client secret. * @return void */ private function confirm_connection( $client_id, $client_secret ) { $had_credentials = Send4_Settings::has_credentials(); $result = self::establish_connection( $client_id, $client_secret ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'error', $had_credentials /* translators: %s: why Send4 refused, e.g. "Send4 rejected these credentials." */ ? sprintf( __( '%s The new credentials were not saved; this site is still using the ones it had.', 'send4' ), $result->get_error_message() ) /* translators: %s: why Send4 refused, e.g. "Send4 rejected these credentials." */ : sprintf( __( '%s The credentials were not saved.', 'send4' ), $result->get_error_message() ), array(), self::CONNECTION_SLUG ); } $this->redirect_with_notice( 'success', /* translators: %s: Send4 account name. */ sprintf( __( 'Connected to %s.', 'send4' ), isset( $result['client']['name'] ) ? (string) $result['client']['name'] : '' ), array(), self::CONNECTION_SLUG ); } /** * Start a connection: introduce this site to Send4 and send the admin there * to approve it. * * @return void */ public function handle_start_connect() { $this->require_capability(); check_admin_referer( 'send4_start_connect' ); $client = new Send4_Api_Client(); $registration = $client->register_site(); if ( is_wp_error( $registration ) ) { $this->redirect_with_notice( 'error', $registration->get_error_message(), array(), self::CONNECTION_SLUG ); } Send4_Settings::update_connection( array( 'connect_id' => $registration['connect_id'], 'connect_secret' => $registration['connect_secret'], ) ); // Off to Send4 to approve. wp_redirect rather than wp_safe_redirect: // the whole point is to leave this site. The destination is not taken // on faith, though — register_site() has already refused anything that // is not a Send4 or development address, because the API is trusted // with the submissions, not with where the administrator's browser // ends up. wp_redirect( $registration['authorize_url'] ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } /** * Finish a connection that Send4 just approved. * * Always redirects. See maybe_complete_connect(), which is the only caller * and where the capability check and the reasoning about nonces live. * * @param string $code The one-time code from the redirect. * @param string $connect_id The connection the code belongs to. * @return void */ private function complete_connect( $code, $connect_id ) { $connection = Send4_Settings::connection(); if ( '' === $connection['connect_id'] || $connection['connect_id'] !== $connect_id ) { $this->redirect_with_notice( 'error', __( 'That connection did not start here. Try connecting again.', 'send4' ), array(), self::CONNECTION_SLUG ); } $client = new Send4_Api_Client(); $credentials = $client->exchange_code( $connection['connect_id'], $connection['connect_secret'], $code ); if ( is_wp_error( $credentials ) ) { $this->redirect_with_notice( 'error', $credentials->get_error_message(), array(), self::CONNECTION_SLUG ); } Send4_Settings::update_connection( array( // One-shot: keeping them would only ever be a replay target. 'connect_id' => '', 'connect_secret' => '', ) ); $this->confirm_connection( $credentials['client_id'], $credentials['client_secret'] ); } /** * Re-run the handshake. * * @return void */ public function handle_test() { $this->require_capability(); check_admin_referer( 'send4_test' ); $result = $this->handshake( true ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'error', $result->get_error_message(), array(), self::CONNECTION_SLUG ); } $flows = isset( $result['flows'] ) ? count( $result['flows'] ) : 0; $this->redirect_with_notice( 'success', sprintf( /* translators: 1: Send4 account name, 2: number of flows. */ _n( 'Connected to %1$s. %2$d flow is listening for WordPress forms.', 'Connected to %1$s. %2$d flows are listening for WordPress forms.', $flows, 'send4' ), isset( $result['client']['name'] ) ? $result['client']['name'] : '', $flows ), array(), self::CONNECTION_SLUG ); } /** * Forget the credentials. * * @return void */ public function handle_disconnect() { $this->require_capability(); check_admin_referer( 'send4_disconnect' ); // Before the credentials go: rows queued before rows carried a client // id must be bound to this connection, or a reconnect to a different // workspace would send them there as new leads. if ( Send4_Queue::table_exists() ) { Send4_Queue::adopt_unowned( Send4_Queue::current_client_id() ); } Send4_Settings::disconnect(); self::forget_handshake(); // The next connection may be a different workspace, which has never been // told anything about this site's forms — so the next push must be // unconditional rather than skipped as "unchanged". Send4_Forms_Sync::forget(); $this->redirect_with_notice( 'success', __( 'Disconnected from Send4. Your field mappings were kept.', 'send4' ), array(), self::CONNECTION_SLUG ); } /** * Save one form's mapping, and enable or disable it. * * @return void */ public function handle_save_mapping() { $this->require_capability(); check_admin_referer( 'send4_save_mapping' ); $form_key = isset( $_POST['form_key'] ) ? sanitize_text_field( wp_unslash( $_POST['form_key'] ) ) : ''; // Looked up once: detection reads every form plugin, and this used to // do it twice before the save had even started. $descriptor = '' === $form_key ? null : $this->find_form( $form_key ); if ( null === $descriptor ) { $this->redirect_with_notice( 'error', __( 'That form no longer exists.', 'send4' ), array(), self::FORMS_SLUG ); } $mapping = array( 'phone' => isset( $_POST['map_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['map_phone'] ) ) : '', 'name' => isset( $_POST['map_name'] ) ? sanitize_text_field( wp_unslash( $_POST['map_name'] ) ) : '', 'email' => isset( $_POST['map_email'] ) ? sanitize_text_field( wp_unslash( $_POST['map_email'] ) ) : '', ); $valid_ids = wp_list_pluck( $descriptor['fields'], 'id' ); foreach ( $mapping as $role => $field_id ) { if ( '' !== $field_id && ! in_array( $field_id, $valid_ids, true ) ) { $mapping[ $role ] = ''; } } $enable = ! empty( $_POST['enabled'] ); // Phone checks (#0688). Saved on BOTH paths below: the enable-without- // phone path redirects early, and dropping the operator's ticks there // would silently switch validation off on their next save. $default_country = isset( $_POST['phone_default_country'] ) ? sanitize_key( wp_unslash( $_POST['phone_default_country'] ) ) : ''; $phone_checks = array( 'validate_phone' => ! empty( $_POST['validate_phone'] ), 'phone_field' => array( 'picker' => ! empty( $_POST['phone_picker'] ), 'default_country' => Send4_Phone_Field::is_country( $default_country ) ? $default_country : '', ), ); if ( $enable && '' === $mapping['phone'] ) { Send4_Settings::update_form( $form_key, array_merge( array( 'mapping' => $mapping, 'name' => $descriptor['title'], ), $phone_checks ) ); $this->redirect_with_notice( 'error', __( 'Pick which field holds the phone number before enabling this form — Send4 follows up by SMS, so a submission without a phone number has nobody to reach.', 'send4' ), array( 'form' => $form_key ), self::FORMS_SLUG ); } Send4_Settings::update_form( $form_key, array_merge( array( 'enabled' => $enable, 'mapping' => $mapping, 'name' => $descriptor['title'], ), $phone_checks ) ); // The mapping decides which fields land in `lead.*` and which keep a // top-level key, so saving one changes the descriptors Send4 holds. self::defer_forms_sync(); $this->redirect_with_notice( 'success', $enable ? __( 'Form saved and enabled.', 'send4' ) : __( 'Form saved. It is not sending to Send4.', 'send4' ), array(), self::FORMS_SLUG ); } /** * Turn one form off from the list screen. * * @return void */ public function handle_disable_form() { $this->require_capability(); check_admin_referer( 'send4_disable_form' ); $form_key = isset( $_POST['form_key'] ) ? sanitize_text_field( wp_unslash( $_POST['form_key'] ) ) : ''; // update_form() creates the entry it is asked for, so an unchecked key // would leave a junk form behind that nothing ever prunes. A form that // is stored but not detected right now (its plugin is deactivated) can // still be switched off. if ( '' === $form_key || ( null === $this->find_form( $form_key ) && ! Send4_Settings::has_form( $form_key ) ) ) { $this->redirect_with_notice( 'error', __( 'That form no longer exists.', 'send4' ), array(), self::FORMS_SLUG ); } Send4_Settings::update_form( $form_key, array( 'enabled' => false ) ); self::defer_forms_sync(); $this->redirect_with_notice( 'success', __( 'Form disabled.', 'send4' ), array(), self::FORMS_SLUG ); } /** * Report the forms to Send4 shortly, rather than while the admin waits. * * Saving a mapping or turning a form off used to push inline, before the * redirect, so during an outage the Save button hung for the whole * timeout — twice over on a path that signs in again — for metadata whose * failure is only ever logged. The push now runs on the next cron tick, * which wp-admin's own next page load spawns. It reads the settings when * it runs, so several saves in a row still send the latest state, and a * push that does not land is retried by the daily sweep as before. * * Same hook and no arguments as the daily sweep, so deactivation's * unschedule() clears this too. * * @return void */ private static function defer_forms_sync() { wp_schedule_single_event( time(), Send4_Forms_Sync::HOOK ); } /** * Report this site's forms to Send4 now. * * The escape hatch for the gap the automatic triggers cannot close: a form * created, renamed or deleted in the CMS fires no hook this plugin sees, so * without this the list is only as fresh as the daily sweep. * * @return void */ public function handle_sync_forms() { $this->require_capability(); check_admin_referer( 'send4_sync_forms' ); $result = Send4_Forms_Sync::maybe_push( true ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'error', $result->get_error_message(), array(), self::FORMS_SLUG ); } if ( null === $result ) { $this->redirect_with_notice( 'error', __( 'Connect this site to Send4 before syncing its forms.', 'send4' ), array(), self::FORMS_SLUG ); } $this->redirect_with_notice( 'success', __( 'Your forms were sent to Send4. They are now selectable on a WordPress trigger.', 'send4' ), array(), self::FORMS_SLUG ); } /** * Drain the retry queue now. * * @return void */ public function handle_run_queue() { $this->require_capability(); check_admin_referer( 'send4_run_queue' ); $summary = Send4_Delivery::process_queue(); $this->redirect_with_notice( 'success', sprintf( /* translators: 1: attempted count, 2: delivered count. */ _n( 'Retried %1$s queued submission; %2$s delivered.', 'Retried %1$s queued submissions; %2$s delivered.', (int) $summary['attempted'], 'send4' ), number_format_i18n( (int) $summary['attempted'] ), number_format_i18n( (int) $summary['delivered'] ) ) ); } /** * Empty the activity log. * * @return void */ public function handle_clear_log() { $this->require_capability(); check_admin_referer( 'send4_clear_log' ); Send4_Logger::clear(); $this->redirect_with_notice( 'success', __( 'Activity log cleared.', 'send4' ) ); } /** * Put dead-lettered submissions back on the queue. * * For after the cause has been fixed — an ability ticked, a key replaced, * the destination corrected — which the plugin has no way to notice. * * @return void */ public function handle_requeue() { $this->require_capability(); check_admin_referer( 'send4_requeue' ); if ( ! Send4_Settings::has_credentials() ) { $this->redirect_with_notice( 'error', __( 'Connect this site to Send4 before resending failed submissions.', 'send4' ) ); } $moved = Send4_Delivery::requeue(); if ( 0 === $moved ) { $this->redirect_with_notice( 'success', __( 'There were no failed submissions for this Send4 account to resend.', 'send4' ) ); } $this->redirect_with_notice( 'success', sprintf( /* translators: %d: number of submissions. */ _n( '%d failed submission will be resent within a minute.', '%d failed submissions will be resent within a minute.', $moved, 'send4' ), $moved ) ); } /** * The capability check every admin-post handler makes first. * * Each handler then calls check_admin_referer() itself, on the next line. * That check used to live in here too, and the nonce was always verified, * but Plugin Check only sees a nonce check written in the same function * that reads $_POST, so a wrapper reads to it, and to a reviewer, as none. * * @return void */ private function require_capability() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to configure Send4.', 'send4' ) ); } } /** * Redirect back to one of our screens carrying a notice. * * The text travels in a short-lived transient keyed to the current user — * the arrangement core uses for settings_errors — and the URL carries only * a marker. Carrying the text in the URL, as this used to, meant anybody * could link an administrator to a Send4 screen showing a notice of their * choosing, in the plugin's own voice; escaping stops markup, not lies. * * @param string $type success|error. * @param string $message Message. * @param array $args Extra query args. * @param string $slug Page slug to land on. * @return void */ private function redirect_with_notice( $type, $message, array $args = array(), $slug = self::PAGE_SLUG ) { $type = 'success' === $type ? 'success' : 'error'; set_transient( self::notice_key(), array( 'type' => $type, 'message' => (string) $message, ), self::NOTICE_CACHE_S ); $url = $this->page_url( array_merge( $args, array( 'send4_notice' => $type ) ), $slug ); wp_safe_redirect( $url ); exit; } /** * The notice carried on the current request, if any. * * One-shot: reading it removes it, so a reload does not repeat an outcome * that is no longer news. * * @return array{type:string,message:string}|null */ public function current_notice() { // The marker only says a notice may be waiting; nothing in the URL is // rendered. A marker with nothing stored behind it — a forged link, or // one older than the transient — shows nothing at all. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $_GET['send4_notice'] ) ) { return null; } $notice = get_transient( self::notice_key() ); if ( ! is_array( $notice ) || empty( $notice['message'] ) || ! is_string( $notice['message'] ) ) { return null; } delete_transient( self::notice_key() ); return array( 'type' => isset( $notice['type'] ) && 'success' === $notice['type'] ? 'success' : 'error', 'message' => $notice['message'], ); } /** * Where the current user's pending notice is kept. * * Per user, so two administrators cannot read each other's outcomes; per * site by virtue of being a transient. * * @return string */ private static function notice_key() { return self::NOTICE_PREFIX . get_current_user_id(); } /** * Human readable label for a stored delivery status. * * @param string $status Stored status. * @return string */ public function status_label( $status ) { switch ( $status ) { case 'delivered': return __( 'Delivered', 'send4' ); case 'duplicate': return __( 'Already received', 'send4' ); case 'unrouted': return __( 'Not connected to a flow', 'send4' ); case 'queued': return __( 'Queued for retry', 'send4' ); case 'failed': return __( 'Failed', 'send4' ); default: return __( 'No submissions yet', 'send4' ); } } /** * Which badge tone a stored delivery status deserves. * * @param string $status Stored status. * @return string One of ok|info|warn|bad|neutral. */ public function status_tone( $status ) { switch ( $status ) { case 'delivered': return 'ok'; case 'duplicate': return 'info'; case 'unrouted': case 'queued': return 'warn'; case 'failed': return 'bad'; default: return 'neutral'; } } /** * A translated label for a flow status Send4 reports. * * The API answers in its own English keywords. The known ones are named in * the admin's language; anything newer is shown as sent rather than hidden. * * @param string $status Flow status from the handshake. * @return string */ public function flow_status_label( $status ) { switch ( (string) $status ) { case '': return __( 'Unknown', 'send4' ); case 'active': return _x( 'Active', 'flow status', 'send4' ); case 'inactive': return _x( 'Inactive', 'flow status', 'send4' ); case 'draft': return _x( 'Draft', 'flow status', 'send4' ); case 'paused': return _x( 'Paused', 'flow status', 'send4' ); case 'archived': return _x( 'Archived', 'flow status', 'send4' ); default: return (string) $status; } } /** * A stored UTC timestamp in the site's own date and time format and zone. * * @param string $utc Timestamp as stored, Y-m-d H:i:s in UTC. * @return string Empty when there is nothing readable to show. */ public function format_utc( $utc ) { $stamp = '' === (string) $utc ? false : strtotime( $utc . ' UTC' ); if ( false === $stamp ) { return ''; } return (string) wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $stamp ); } /** * Print a nonce for one of the several small forms on a screen. * * Core's wp_nonce_field() prints id="_wpnonce" — and a referer field with an id * of its own — every time it is called, so a screen with more than one * form ends up with duplicate ids. check_admin_referer() needs only the * field's name, and the handlers redirect to a fixed screen rather than to * the referer. * * @param string $action Nonce action. * @return void */ public function nonce_field( $action ) { echo ''; } }