repo = $repo; $this->presenter = $presenter; $this->votes = $votes; } public function hooks(): void { add_filter( 'redirect_canonical', [ $this, 'canonical' ] ); add_filter( 'pre_handle_404', [ $this, 'pre_handle_404' ], 10, 2 ); add_action( 'template_redirect', [ $this, 'route' ] ); add_action( 'admin_post_nopriv_poller_vote', [ $this, 'post_vote' ] ); add_action( 'admin_post_poller_vote', [ $this, 'post_vote' ] ); } /** * @param mixed $redirect * @return mixed */ public function canonical( $redirect ) { if ( get_query_var( 'poller_view' ) ) { return false; } return $redirect; } /** * @param mixed $handled * @param \WP_Query $query * @return mixed */ public function pre_handle_404( $handled, $query ) { unset( $query ); if ( get_query_var( 'poller_view' ) ) { return true; } return $handled; } public function route(): void { $view = (string) get_query_var( 'poller_view' ); if ( 'join' === $view ) { $this->render_join(); exit; } if ( 'poll' === $view || 'board' === $view ) { $this->render_poll( $view ); exit; } } public function post_vote(): void { $code = isset( $_POST['code'] ) ? Codes::normalize( sanitize_text_field( wp_unslash( $_POST['code'] ) ) ) : ''; $target = Codes::is_valid( $code ) ? Plugin::poll_url( $code ) : Plugin::join_url(); $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'poller_vote_' . $code ) ) { $this->redirect_vote_error( $target, 'poller_nonce' ); } // Values stay as text so BallotRules rejects a negative or fractional // count instead of absint() quietly turning it into a valid one. $choices = []; if ( isset( $_POST['words'] ) && is_array( $_POST['words'] ) ) { // Word cloud entries. BallotRules::words() drops control characters, // and every place a word is shown escapes it. sanitize_text_field() // would store "<3" as "<3". $choices = array_values( array_filter( wp_unslash( $_POST['words'] ), 'is_string' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } elseif ( isset( $_POST['choices'] ) && is_array( $_POST['choices'] ) ) { $choices = map_deep( wp_unslash( $_POST['choices'] ), 'sanitize_text_field' ); } elseif ( isset( $_POST['choices'] ) ) { $choices = [ sanitize_text_field( wp_unslash( $_POST['choices'] ) ) ]; } $result = $this->votes->vote( $code, $choices ); if ( is_wp_error( $result ) ) { $this->redirect_vote_error( $target, $result->get_error_code() ); } wp_safe_redirect( $target ); exit; } private function render_join(): void { $error = ''; $code_value = ''; // Public join lookup. The code only selects a poll to redirect to. if ( isset( $_GET['code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $code_value = sanitize_text_field( wp_unslash( $_GET['code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $raw = Codes::normalize( $code_value ); if ( Codes::has_ambiguous( $raw ) ) { $error = __( 'That code has a character that is not used. Codes leave out 0, 1, I, L, and O.', 'polenta' ); } elseif ( ! Codes::is_valid( $raw ) ) { $error = __( 'Enter the 6-character code from the room.', 'polenta' ); } elseif ( $this->repo->get_poll_by_code( $raw ) === null ) { $error = __( 'That code does not match a poll.', 'polenta' ); } else { wp_safe_redirect( Plugin::poll_url( $raw ) ); exit; } } $body = $this->capture( 'join', [ 'error' => $error, 'code_value' => $code_value, 'join_url' => Plugin::join_url(), ] ); $this->send( __( 'Enter the code', 'polenta' ), 'poller-body poller-body--join', $body, 200, false ); } private function render_poll( string $view ): void { $code = Codes::normalize( (string) get_query_var( 'poller_code' ) ); $poll = Codes::is_valid( $code ) ? $this->repo->get_poll_by_code( $code ) : null; if ( ! $poll ) { $body = $this->capture( 'missing', [ 'join_url' => Plugin::join_url(), ] ); $this->send( __( 'Poll not found', 'polenta' ), 'poller-body', $body, 404, false ); return; } Voter::ensure(); $hash = Voter::hash( Voter::token() ); $state = $this->presenter->payload( $poll, $hash ); $title = (string) $state['question']; $can_manage = current_user_can( 'manage_options' ); // Hide buttons on the poll page's results, for admins. Never on the // room display, which is up on a screen. $moderate = null; if ( $can_manage && 'poll' === $view && 'words' === $state['selection'] ) { $moderate = [ 'url' => admin_url( 'admin-post.php' ), 'poll' => (int) $poll['id'], 'nonce' => wp_create_nonce( 'poller_hide_word_' . (int) $poll['id'] ), 'hide' => __( 'Hide', 'polenta' ), /* translators: %s is a word in the word cloud. */ 'hideLabel' => __( 'Hide "%s" from the results', 'polenta' ), ]; } $client = $this->client_state( $state ); $client['moderate'] = $moderate; $body = $this->capture( 'board' === $view ? 'board' : 'poll', [ 'state' => $state, 'client' => $client, 'moderate' => $moderate, 'error' => $this->vote_error( $state ), 'join_url' => Plugin::join_url(), 'join_human' => $this->human_url( Plugin::join_url() ), 'post_url' => admin_url( 'admin-post.php' ), 'nonce' => wp_create_nonce( 'poller_vote_' . $state['code'] ), 'can_manage' => $can_manage, 'total_label' => self::total_label( (int) $state['ballots'], $state ), 'summary' => $this->summary( $state ), 'your_vote' => __( 'Your vote', 'polenta' ), ] ); $class = 'board' === $view ? 'poller-body poller-body--board' : 'poller-body'; $this->send( $title, $class, $body, 200, 'board' === $view ); } /** * @param array $state * @return array */ private function client_state( array $state ): array { $code = rawurlencode( (string) $state['code'] ); $state['nonce'] = wp_create_nonce( 'poller_vote_' . $state['code'] ); $state['rest'] = rest_url( 'poller/v1/polls/' . $code ); $state['restVotes'] = rest_url( 'poller/v1/polls/' . $code . '/votes' ); $state['i18n'] = [ 'oneVote' => __( '1 vote', 'polenta' ), /* translators: %s is the number of ballots. */ 'manyVotes' => __( '%s votes', 'polenta' ), 'noVotes' => __( 'No votes yet', 'polenta' ), 'yourVote' => __( 'Your vote', 'polenta' ), 'vote' => __( 'Vote', 'polenta' ), 'updateVote' => __( 'Update vote', 'polenta' ), 'saving' => __( 'Saving vote', 'polenta' ), 'paused' => __( 'Live results paused. Still showing the last update.', 'polenta' ), 'chooseOne' => __( 'Choose one option.', 'polenta' ), 'chooseSome' => __( 'Choose at least one option.', 'polenta' ), 'voteFailed' => __( 'The vote did not save. Try again.', 'polenta' ), 'qr' => __( 'QR code that opens this poll', 'polenta' ), ]; if ( 'dots' === $state['selection'] ) { $budget = (int) $state['budget']; $state['i18n'] += [ /* translators: 1: dots not placed yet, 2: dots each voter has. */ 'dotsLeft' => _n( '%1$s of %2$s dot left', '%1$s of %2$s dots left', $budget, 'polenta' ), 'toPlaceOne' => __( '1 dot left to place', 'polenta' ), /* translators: %s is the number of dots not placed yet. */ 'toPlaceMany' => __( '%s dots left to place', 'polenta' ), 'placeDot' => __( 'Place a dot to vote', 'polenta' ), /* translators: %s is how many dots this browser put on an item. */ 'yourDots' => __( 'Your dots: %s', 'polenta' ), 'oneDot' => __( '1 dot', 'polenta' ), /* translators: %s is the number of dots. */ 'manyDots' => __( '%s dots', 'polenta' ), /* translators: %s is the number of items. */ 'showAll' => __( 'Show all %s', 'polenta' ), 'showTop' => __( 'Show the top 10', 'polenta' ), ]; } if ( 'words' === $state['selection'] ) { $state['i18n'] += [ 'enterWord' => __( 'Enter at least one word.', 'polenta' ), 'yourWord' => __( 'Your word', 'polenta' ), 'noWords' => __( 'Words will show up here as people answer.', 'polenta' ), /* translators: %s is the number of words. */ 'showAll' => __( 'Show all %s', 'polenta' ), 'showTop' => __( 'Show the top 10', 'polenta' ), ]; } return $state; } /** * @param array $state */ private function vote_error( array $state ): string { // Status key added by redirect_vote_error() and matched against a fixed list. if ( ! isset( $_GET['vote'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return ''; } $code = sanitize_key( wp_unslash( $_GET['vote'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended return (string) VoteService::error_message( $code, $state ); } /** * @param array $state */ private function summary( array $state ): string { $parts = [ self::total_label( (int) $state['ballots'], $state ) ]; if ( 'words' === $state['selection'] ) { foreach ( $state['words'] as $word ) { $parts[] = $word['text'] . ' ' . (int) $word['count']; } return implode( '. ', $parts ) . '.'; } $dots = 'dots' === $state['selection']; foreach ( $state['choices'] as $choice ) { $votes = (int) $choice['votes']; if ( ! $dots ) { $parts[] = $choice['label'] . ' ' . $votes; continue; } $parts[] = $choice['label'] . ' ' . ( 1 === $votes ? __( '1 dot', 'polenta' ) : sprintf( /* translators: %s is the number of dots. */ __( '%s dots', 'polenta' ), number_format_i18n( $votes ) ) ); } return implode( '. ', $parts ) . '.'; } /** * @param array $state */ public static function total_label( int $ballots, array $state = [] ): string { if ( 0 === $ballots ) { return __( 'No votes yet', 'polenta' ); } if ( 1 === $ballots ) { $label = __( '1 vote', 'polenta' ); } else { $label = sprintf( /* translators: %s is the number of ballots. */ __( '%s votes', 'polenta' ), number_format_i18n( $ballots ) ); } if ( ( $state['selection'] ?? '' ) !== 'dots' ) { return $label; } $dots = (int) $state['dots']; $dots_label = 1 === $dots ? __( '1 dot', 'polenta' ) : sprintf( /* translators: %s is the number of dots. */ __( '%s dots', 'polenta' ), number_format_i18n( $dots ) ); return $label . ' ยท ' . $dots_label; } private function human_url( string $url ): string { $human = preg_replace( '#^https?://#', '', untrailingslashit( $url ) ); return $human ? $human : $url; } private function redirect_vote_error( string $target, string $code ): void { wp_safe_redirect( add_query_arg( 'vote', sanitize_key( $code ), $target ) ); exit; } /** * @param array $data */ private function capture( string $template, array $data ): string { $path = POLLER_DIR . 'templates/' . $template . '.php'; ob_start(); ( static function ( string $path, array $data ) { extract( $data, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract -- Template scope; keys are controlled by capture() callers. require $path; } )( $path, $data ); return (string) ob_get_clean(); } private function send( string $title, string $body_class, string $body, int $status, bool $qr ): void { status_header( $status ); nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow', true ); wp_enqueue_style( 'poller-fonts', POLLER_URL . 'assets/css/fonts.css', [], POLLER_VERSION ); wp_enqueue_style( 'poller', POLLER_URL . 'assets/css/poller.css', [ 'poller-fonts' ], POLLER_VERSION ); $scripts = []; if ( $qr ) { wp_enqueue_script( 'poller-qrcode', POLLER_URL . 'assets/js/qrcode.js', [], POLLER_VERSION, false ); $scripts[] = 'poller-qrcode'; } if ( str_contains( $body, 'data-poller-cloud' ) ) { wp_enqueue_script( 'poller-cloud', POLLER_URL . 'assets/js/cloud.js', [], POLLER_VERSION, false ); $scripts[] = 'poller-cloud'; } if ( $qr || str_contains( $body, 'id="poller-state"' ) ) { wp_enqueue_script( 'poller', POLLER_URL . 'assets/js/poller.js', $qr ? [ 'poller-qrcode' ] : [], POLLER_VERSION, false ); $scripts[] = 'poller'; } ?> > <?php echo esc_html( $title ); ?>