Clientsask. Every form carries a nonce; every action checks manage_options first. if ( ! defined( 'ABSPATH' ) ) { exit; } class Clientsask_Admin { const NONCE = 'clientsask_do'; private $settings; private $sync; private $actions; private $log; private $seo; public function __construct( Clientsask_Settings $settings, Clientsask_Sync $sync, Clientsask_Actions $actions, Clientsask_Log $log, Clientsask_Seo $seo ) { $this->settings = $settings; $this->sync = $sync; $this->actions = $actions; $this->log = $log; $this->seo = $seo; } public function register() { add_action( 'admin_menu', array( $this, 'add_menu' ) ); } public function add_menu() { add_options_page( 'Clientsask', 'Clientsask', 'manage_options', 'clientsask', array( $this, 'render_page' ) ); } /** Returns array( 'ok' => bool, 'message' => string ) or null when no form was sent. */ public function handle_post( $post ) { if ( ! is_array( $post ) || empty( $post['clientsask_do'] ) || ! is_string( $post['clientsask_do'] ) ) { return null; } if ( ! current_user_can( 'manage_options' ) ) { return array( 'ok' => false, 'message' => 'You do not have permission.' ); } $nonce = isset( $post['_wpnonce'] ) && is_string( $post['_wpnonce'] ) ? $post['_wpnonce'] : ''; if ( ! wp_verify_nonce( $nonce, self::NONCE ) ) { return array( 'ok' => false, 'message' => 'This page expired. Reload it and try again.' ); } $row = isset( $post['row'] ) && is_string( $post['row'] ) ? sanitize_text_field( $post['row'] ) : ''; switch ( $post['clientsask_do'] ) { case 'save': return $this->save( $post ); case 'disconnect': $this->settings->delete_token(); return array( 'ok' => true, 'message' => 'Disconnected. The connection code was deleted from this site.' ); case 'check': $res = $this->sync->run( true ); return array( 'ok' => $res['level'] === 'ok', 'message' => $res['message'] ); case 'apply': return $this->actions->apply( $row ); case 'undo': return $this->actions->undo( $row ); case 'dismiss': return $this->actions->dismiss( $row ); } return array( 'ok' => false, 'message' => 'Unknown action.' ); } private function save( $post ) { $base = isset( $post['base_url'] ) && is_string( $post['base_url'] ) ? $post['base_url'] : ''; $before = $this->settings->get_base_url(); if ( ! $this->settings->set_base_url( $base ) ) { return array( 'ok' => false, 'message' => 'The address must start with https:// (or http:// with localhost, for a demo on this computer). Nothing was saved.' ); } $address_changed = $this->settings->get_base_url() !== $before; $review_changed = $this->settings->review_first() !== ! empty( $post['review_first'] ); $this->settings->set_review_first( ! empty( $post['review_first'] ) ); $token = isset( $post['token'] ) && is_string( $post['token'] ) ? trim( $post['token'] ) : ''; if ( $token !== '' && ! $this->settings->set_token( $token ) ) { return array( 'ok' => false, 'message' => 'That connection code does not look right. It starts with cs_live_.' ); } if ( $token === '' ) { // why: a changed review-first box is checked at once, so the dashboard's "goes live by itself" line follows it now, not an hour later. if ( ( ! $address_changed && ! $review_changed ) || $this->settings->get_token() === '' ) { return array( 'ok' => true, 'message' => 'Saved.' ); } // why: a new address for a stored code is a new connection: forget the old status (it may be "rejected") and check it. $this->settings->clear_status(); } // why: saving a code only stores it. Check right away so the notice (and the Clientsask dashboard) tell the truth. // The code stays saved even when the check fails. $res = $this->sync->run( true ); if ( $res['level'] === 'ok' ) { return array( 'ok' => true, 'message' => 'Saved. ' . $res['message'] ); } return array( 'ok' => false, 'message' => 'Saved, but not connected. ' . $res['message'] ); } public function render_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to see this page.', 'clientsask' ) ); } $notice = null; if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] === 'POST' ) { // phpcs:ignore WordPress.Security.NonceVerification -- checked in handle_post $notice = $this->handle_post( wp_unslash( $_POST ) ); // phpcs:ignore WordPress.Security.NonceVerification } echo $this->render( $notice ); // phpcs:ignore WordPress.Security.EscapeOutput -- render() escapes each value } /** $ask, when given, is a question the browser asks before the form is sent. */ private function form( $do, $row, $label, $class, $ask = '' ) { return '
' . wp_nonce_field( self::NONCE, '_wpnonce', true, false ) . '' . ( $row !== '' ? '' : '' ) . '
'; } public function render( $notice = null ) { $token = $this->settings->get_token(); $status = $this->settings->get_status(); $h = '

Clientsask

'; if ( $notice ) { $h .= '

' . esc_html( $notice['message'] ) . '

'; } // why: each result is said once. A notice from this request that already says it (a check that just ran) replaces the line below. $said = $notice ? strtolower( $notice['message'] ) : ''; if ( ! empty( $status['rejected'] ) && strpos( $said, 'not accepted' ) === false ) { $h .= '

' . esc_html( 'Connection code not accepted, create a new one in your Clientsask dashboard.' ) . '

'; } // Connection. $h .= '

Connection

' . wp_nonce_field( self::NONCE, '_wpnonce', true, false ) . '' . '' . '
Connection code' . '
Address
Title changes' . '

' . esc_html( 'Off: a title or description the owner approves in Clientsask goes live on the next check (about every hour). Every change can be undone below.' ) . '

' . '
'; if ( $token !== '' ) { $h .= '

' . $this->form( 'check', '', 'Check for fixes', 'button-primary' ) . $this->form( 'disconnect', '', 'Disconnect', '', 'Disconnect from Clientsask? This site stops getting fixes until you paste a new connection code from your Clientsask dashboard.' ) . '

'; if ( ! empty( $status['time'] ) ) { $msg = empty( $status['rejected'] ) && isset( $status['message'] ) ? (string) $status['message'] : ''; $say = $msg !== '' && strpos( $said, strtolower( $msg ) ) === false ? ' ' . $msg : ''; $h .= '

Last check: ' . esc_html( gmdate( 'Y-m-d H:i', (int) $status['time'] ) ) . ' UTC.' . esc_html( $say ) . '

'; } } if ( $this->seo->needs_setup() ) { $h .= '

' . esc_html( Clientsask_Seo::RANK_MATH_SETUP ) . '

'; } // Staged changes: only with review-first on, or a change that could not be applied by itself. $rows = $this->log->by( 'seo', array( 'staged' ) ); if ( $rows || $this->settings->review_first() ) { $h .= '

Waiting for Apply

'; } if ( ! $rows && $this->settings->review_first() ) { $h .= '

Nothing waiting.

'; } foreach ( $rows as $r ) { $cur = $this->seo->current( $this->seo->target_of( $r ), $r['plugin'] ); $h .= '

' . esc_html( $this->seo->label( $this->seo->target_of( $r ) ) ) . '

' . '

Title now: ' . esc_html( $cur ? $cur['title'] : '' ) . '
Title proposed: ' . esc_html( $r['new_title'] ) . '

' . '

Description now: ' . esc_html( $cur ? $cur['desc'] : '' ) . '
Description proposed: ' . esc_html( $r['new_desc'] ) . '

' . $this->form( 'apply', $r['id'], 'Apply', 'button-primary' ) . $this->form( 'dismiss', $r['id'], 'Dismiss', '' ) . '
'; } // Manual paste. $rows = $this->log->by( 'seo', array( 'manual' ) ); if ( $rows ) { $h .= '

Needs manual paste

'; } foreach ( $rows as $r ) { $h .= '

' . esc_html( $r['url'] ) . '

' . esc_html( $r['reason'] ) . '

' . '

Title

' . '

Description

' . $this->form( 'dismiss', $r['id'], 'Dismiss', '' ) . '
'; } // Drafts. $h .= '

Draft posts created

'; $rows = $this->log->by( 'draft', array( 'draft' ) ); if ( ! $rows ) { $h .= '

None yet.

'; } foreach ( $rows as $r ) { $h .= '

' . esc_html( $r['title'] ) . '

'; } // Applied. $h .= '

Applied changes

'; $rows = $this->log->by( 'seo', array( 'applied' ) ); if ( ! $rows ) { $h .= '

None yet.

'; } foreach ( $rows as $r ) { $h .= '

' . esc_html( $this->seo->label( $this->seo->target_of( $r ) ) ) . '

' . '

Title: ' . esc_html( $r['new_title'] ) . '
Description: ' . esc_html( $r['new_desc'] ) . '

' . ( ! empty( $r['waits_setup'] ) && $this->seo->needs_setup() ? '

' . esc_html( 'Saved, not on your site yet: finish Rank Math\'s setup first.' ) . '

' : '' ) . $this->form( 'undo', $r['id'], 'Undo', '' ) . '
'; } return $h . '
'; } }