plugin = $plugin; } /** * Registers the handlers. Only logged-in users reach admin_post_* (no "nopriv" handlers). */ public function register(): void { add_action( 'admin_post_' . self::SAVE_SETTINGS, array( $this, 'save_settings' ) ); add_action( 'admin_post_' . self::OAUTH_START, array( $this, 'oauth_start' ) ); add_action( 'admin_post_' . self::OAUTH_RETURN, array( $this, 'oauth_return' ) ); add_action( 'admin_post_' . self::DISCONNECT, array( $this, 'disconnect' ) ); add_action( 'admin_post_' . self::TEST, array( $this, 'test_connection' ) ); add_action( 'admin_post_' . self::TOGGLE_PAUSE, array( $this, 'toggle_pause' ) ); add_action( 'admin_post_' . self::OAUTH_PASTE, array( $this, 'oauth_paste' ) ); add_action( 'admin_post_' . self::CONNECT_SAVE, array( $this, 'connect_save' ) ); } /** * This site's own OAuth callback address. It contains no "&" or "#", which marketplaces do not accept in a redirect address. */ public static function site_callback_uri(): string { return admin_url( 'admin-post.php?action=' . self::OAUTH_RETURN ); } /** * Whether the marketplace will send the owner to another site (the result must then be pasted back). * * @param string $redirect_uri Return address registered at the marketplace. */ public static function needs_paste( string $redirect_uri ): bool { $registered = wp_parse_url( $redirect_uri, PHP_URL_HOST ); $here = wp_parse_url( admin_url(), PHP_URL_HOST ); return strtolower( (string) $registered ) !== strtolower( (string) $here ); } /** * Whether the current user pressed "Connect" in the last 10 minutes and has not finished yet. */ public static function connect_pending(): bool { return false !== get_transient( self::STATE_TRANSIENT . get_current_user_id() ); } /** * The channel a button or form acts on: the "channel" field, else the default channel. */ private function posted_channel(): ChannelInterface { return $this->plugin->requested_channel(); } // ------------------------------------------------------------------ Handlers. /** * Saves the settings form. */ public function save_settings(): void { $this->guard( self::SAVE_SETTINGS ); $settings = $this->plugin->settings(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- nonce checked in guard(); every field is sanitized individually below. $in = isset( $_POST['tidelist'] ) && is_array( $_POST['tidelist'] ) ? wp_unslash( $_POST['tidelist'] ) : array(); $changes = array(); // Each marketplace checks and saves its own fields. foreach ( $this->plugin->channels()->all() as $channel ) { $posted = ChannelFields::posted( $channel->id() ); if ( $posted ) { $this->flash_all( $channel->save_settings( $posted ) ); } } // Safety switches. $changes['paused'] = ! empty( $in['paused'] ); $want_dry_run = ! empty( $in['dry_run'] ); if ( ! $want_dry_run && $settings->is_dry_run() ) { if ( self::LIVE_PHRASE !== trim( (string) ( $in['dry_run_confirm'] ?? '' ) ) ) { Flash::add( 'error', __( 'Dry-run stays ON. To switch it off, type LIVE in the confirmation box.', 'tidelist' ) ); $want_dry_run = true; } else { $this->plugin->logger()->log( Logger::WARNING, 'settings', 'Dry-run switched OFF by ' . wp_get_current_user()->user_login . '.' ); } } elseif ( $want_dry_run && ! $settings->is_dry_run() ) { $this->plugin->logger()->log( Logger::INFO, 'settings', 'Dry-run switched ON by ' . wp_get_current_user()->user_login . '.' ); } $changes['dry_run'] = $want_dry_run; // Publishing defaults. $image_raw = trim( (string) ( $in['image_base_url'] ?? '' ) ); $image_base = untrailingslashit( esc_url_raw( $image_raw, array( 'https' ) ) ); if ( '' === $image_raw ) { $changes['image_base_url'] = ''; // Empty = this site's own address. } elseif ( '' !== $image_base ) { $changes['image_base_url'] = $image_base; } else { Flash::add( 'error', __( 'The public image address must start with https://. The previous value was kept.', 'tidelist' ) ); } $changes['delete_on_uninstall'] = ! empty( $in['delete_on_uninstall'] ); // Advert text: templates and footer. $changes['title_template'] = sanitize_text_field( (string) ( $in['title_template'] ?? '' ) ); $changes['description_template'] = sanitize_textarea_field( (string) ( $in['description_template'] ?? '' ) ); $footer = sanitize_textarea_field( (string) ( $in['shop_footer'] ?? '' ) ); if ( ForbiddenContent::find( $footer ) ) { Flash::add( 'error', __( 'Marketplaces do not allow e-mail or web addresses in advert text. Please remove them from the footer text; the previous value was kept.', 'tidelist' ) ); } else { $changes['shop_footer'] = $footer; } // Advert contact. $changes['contact_name'] = sanitize_text_field( (string) ( $in['contact_name'] ?? '' ) ); $phone = preg_replace( '/[\s\-\/().]+/', '', (string) ( $in['contact_phone'] ?? '' ) ); if ( '' === $phone || preg_match( '/^\+?\d{7,15}$/', $phone ) ) { $changes['contact_phone'] = $phone; } else { Flash::add( 'error', __( 'The phone number looks invalid (use digits, optionally with a leading +). The previous value was kept.', 'tidelist' ) ); } $settings->update( $changes ); $this->plugin->logger()->log( Logger::INFO, 'settings', 'Settings saved.', array( 'fields' => array_keys( $changes ) ) ); Flash::add( 'success', __( 'Settings saved.', 'tidelist' ) ); $this->back(); } /** * Saves a step of the connect wizard (the fields of that step only). */ public function connect_save(): void { $this->guard( self::CONNECT_SAVE ); $channel = $this->posted_channel(); $posted = ChannelFields::posted( $channel->id() ); $this->flash_all( $channel->save_settings( $posted ) ); $this->plugin->logger()->log( Logger::INFO, 'settings', sprintf( '%1$s settings saved by %2$s.', $channel->label(), wp_get_current_user()->user_login ) ); Flash::add( 'success', Text::channel( __( 'Saved. Next: press "Connect {channel} account" once the steps above are done.', 'tidelist' ), $channel->label() ) ); wp_safe_redirect( Menu::connect_url( $channel->id() ) ); exit; } /** * Sends the owner to the marketplace consent screen. */ public function oauth_start(): void { $this->guard( self::OAUTH_START ); $channel = $this->posted_channel(); $label = $channel->label(); if ( $this->plugin->settings()->is_paused() ) { Flash::add( 'error', Text::channel( __( 'The plugin is paused. Resume it before connecting the {channel} account.', 'tidelist' ), $label ) ); $this->back(); } $state = wp_generate_password( 32, false, false ); set_transient( self::STATE_TRANSIENT . get_current_user_id(), array( 'state' => $state, 'channel' => $channel->id(), ), 10 * MINUTE_IN_SECONDS ); try { $url = $channel->connect_url( $state, $channel->redirect_uri() ); } catch ( AuthException $e ) { Flash::add( 'error', Text::channel( __( 'Enter and save the {channel} Client ID and Client Secret first.', 'tidelist' ), $label ) ); $this->back(); return; } $this->plugin->logger()->log( Logger::INFO, 'auth', sprintf( 'Redirecting to the %s consent screen.', $label ) ); wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- external marketplace consent screen. exit; } /** * OAuth callback from the marketplace. Protected by the one-time "state" value instead of a nonce, * because the request comes from the marketplace's redirect. */ public function oauth_return(): void { if ( ! current_user_can( Plugin::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'tidelist' ), 403 ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- verified with the OAuth state below. $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $this->complete_oauth( $code, $state, $error ); } /** * Finishes the connection from a callback address pasted by the owner (local copy). */ public function oauth_paste(): void { $this->guard( self::OAUTH_PASTE ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked in guard(). $pasted = isset( $_POST['tidelist_callback_url'] ) ? sanitize_text_field( wp_unslash( $_POST['tidelist_callback_url'] ) ) : ''; $parts = CallbackUrl::parse( $pasted ); $this->complete_oauth( $parts['code'], $parts['state'], $parts['error'] ); } /** * Verifies the one-time state and exchanges the code for tokens. * * @param string $code One-time code from the marketplace. * @param string $state State returned by the marketplace. * @param string $error Error returned by the marketplace. */ private function complete_oauth( string $code, string $state, string $error ): void { $key = self::STATE_TRANSIENT . get_current_user_id(); $expected = get_transient( $key ); delete_transient( $key ); $channel = $this->plugin->channel(); if ( is_array( $expected ) && isset( $expected['channel'] ) && $this->plugin->channels()->has( (string) $expected['channel'] ) ) { $channel = $this->plugin->channels()->get( (string) $expected['channel'] ); } $label = $channel->label(); if ( ! is_array( $expected ) || '' === $state || ! hash_equals( (string) ( $expected['state'] ?? '' ), $state ) ) { $this->plugin->logger()->log( Logger::WARNING, 'auth', 'OAuth callback rejected: missing or wrong state.' ); Flash::add( 'error', Text::channel( __( 'The {channel} connection could not be verified (the link expired or was not started here). Please press "Connect {channel} account" again.', 'tidelist' ), $label ) ); $this->back(); } if ( '' !== $error || '' === $code ) { $this->plugin->logger()->log( Logger::WARNING, 'auth', sprintf( '%s did not grant access.', $label ), array( 'error' => $error ) ); Flash::add( 'error', Text::channel( __( '{channel} did not grant access, so the account was not connected.', 'tidelist' ), $label ) ); $this->back(); } try { $channel->exchange_code( $code, $channel->redirect_uri() ); $name = $channel->load_account(); Flash::add( 'success', /* translators: %s: marketplace account name */ sprintf( Text::channel( __( '{channel} account connected: %s.', 'tidelist' ), $label ), $name ) ); } catch ( ChannelException $e ) { Flash::add( 'error', /* translators: %s: error details */ sprintf( Text::channel( __( 'Connecting the {channel} account failed: %s', 'tidelist' ), $label ), $this->describe( $e, $label ) ) ); } $this->back(); } /** * Forgets the marketplace account tokens. */ public function disconnect(): void { $this->guard( self::DISCONNECT ); $channel = $this->posted_channel(); $channel->disconnect(); $this->plugin->logger()->log( Logger::INFO, 'auth', sprintf( '%1$s account disconnected by %2$s.', $channel->label(), wp_get_current_user()->user_login ) ); Flash::add( 'success', Text::channel( __( '{channel} account disconnected. The stored tokens were deleted.', 'tidelist' ), $channel->label() ) ); $this->back(); } /** * Read-only connection test. */ public function test_connection(): void { $this->guard( self::TEST ); $channel = $this->posted_channel(); try { Flash::add( 'success', $channel->test_connection() ); } catch ( ChannelException $e ) { Flash::add( 'error', /* translators: %s: error details */ sprintf( __( 'Connection test failed: %s', 'tidelist' ), $this->describe( $e, $channel->label() ) ) ); } $this->back(); } /** * Flips the global pause switch. */ public function toggle_pause(): void { $this->guard( self::TOGGLE_PAUSE ); $settings = $this->plugin->settings(); $paused = ! $settings->is_paused(); $settings->update( array( 'paused' => $paused ) ); $this->plugin->logger()->log( Logger::WARNING, 'settings', ( $paused ? 'Paused' : 'Resumed' ) . ' by ' . wp_get_current_user()->user_login . '.' ); Flash::add( 'success', $paused ? __( 'Paused. Nothing will be sent to any marketplace until you resume.', 'tidelist' ) : __( 'Resumed.', 'tidelist' ) ); $this->back(); } // ------------------------------------------------------------------ Helpers. /** * Shows the messages a channel returned when saving its settings. * * @param array $messages List of ['error'|'warning'|'success', text]. */ private function flash_all( array $messages ): void { foreach ( $messages as $message ) { Flash::add( (string) $message[0], (string) $message[1] ); } } /** * Plain-language description of a client error. * * @param ChannelException $e Error. * @param string $label Marketplace name. */ private function describe( ChannelException $e, string $label ): string { if ( $e instanceof BlockedException ) { switch ( $e->reason() ) { case BlockedException::PAUSED: return __( 'the plugin is paused.', 'tidelist' ); case BlockedException::COOLDOWN: return Text::channel( __( '{channel} asked us to slow down; please try again later.', 'tidelist' ), $label ); case BlockedException::RATE_LIMIT: return __( 'the local safety limit of API calls was reached; please wait 5 minutes.', 'tidelist' ); default: return __( 'this request is not allowed by the plugin.', 'tidelist' ); } } if ( $e instanceof AuthException ) { switch ( $e->reason() ) { case AuthException::NO_CREDENTIALS: return Text::channel( __( 'the {channel} Client ID and Client Secret are not set.', 'tidelist' ), $label ); case AuthException::NOT_CONNECTED: return Text::channel( __( 'no {channel} account is connected.', 'tidelist' ), $label ); case AuthException::EXPIRED: return Text::channel( __( 'the {channel} connection expired; please reconnect the {channel} account.', 'tidelist' ), $label ); default: return Text::channel( __( '{channel} rejected the login.', 'tidelist' ), $label ); } } if ( $e instanceof ApiException ) { /* translators: 1: HTTP status code, 2: message from the marketplace */ return sprintf( Text::channel( __( '{channel} answered with error %1$d (%2$s).', 'tidelist' ), $label ), $e->status(), $e->getMessage() ); } if ( $e instanceof TransportException ) { /* translators: %s: network error */ return sprintf( Text::channel( __( '{channel} could not be reached (%s).', 'tidelist' ), $label ), $e->getMessage() ); } return $e->getMessage(); } /** * Capability and nonce check. * * @param string $action Nonce action. */ private function guard( string $action ): void { if ( ! current_user_can( Plugin::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'tidelist' ), 403 ); } check_admin_referer( $action ); } /** * Redirects back to the settings page and stops. */ private function back(): void { // Back to the page the form was sent from; after the return from the marketplace (no referrer) to the connect wizard. $referer = wp_get_referer(); wp_safe_redirect( $referer ? $referer : Menu::connect_url() ); exit; } }