'POST', 'callback' => array( $this, 'rest_unsubscribe' ), // Public by design: authorization is the HMAC "key" bound to the subscription ID, verified with hash_equals() in the callback (RFC 8058 one-click clients cannot authenticate). 'permission_callback' => '__return_true', 'args' => array( 'sid' => array( 'required' => true, 'sanitize_callback' => 'absint', ), 'key' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'scope' => array( 'default' => 'post', 'sanitize_callback' => 'sanitize_key', ), ), ) ); } /** * REST callback. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_unsubscribe( $request ) { $sub = $this->verify( absint( $request->get_param( 'sid' ) ), (string) $request->get_param( 'key' ) ); if ( ! $sub ) { return new \WP_Error( 'replyping_invalid', __( 'This link is invalid.', 'replyping' ), array( 'status' => 403 ) ); } $this->do_unsubscribe( $sub, sanitize_key( (string) $request->get_param( 'scope' ) ) ); return new \WP_REST_Response( array( 'success' => true ), 200 ); } /** * Validate sid + key. * * @param int $sid Subscription ID. * @param string $key Supplied key. * @return object|null */ private function verify( $sid, $key ) { $sub = $sid ? Db::get( $sid ) : null; if ( $sub && hash_equals( Db::unsubscribe_key( $sub ), $key ) ) { return $sub; } return null; } /** * Apply unsubscribe. * * @param object $sub Subscription. * @param string $scope 'post' or 'all'. */ private function do_unsubscribe( $sub, $scope ) { if ( 'all' === $scope ) { Db::unsubscribe( $sub->email, null ); } else { $scope = 'post'; Db::unsubscribe( $sub->email, (int) $sub->post_id ); } do_action( 'replyping_unsubscribed', $sub, $scope ); } /** * Front-end handler for confirm / unsubscribe links. */ public function maybe_handle() { // Token/HMAC-authenticated public endpoint; nonces do not apply to emailed links. // phpcs:disable WordPress.Security.NonceVerification if ( empty( $_REQUEST['replyping_action'] ) ) { return; } $action = sanitize_key( wp_unslash( $_REQUEST['replyping_action'] ) ); $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; $token = isset( $_REQUEST['token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['token'] ) ) : ''; $sid = isset( $_REQUEST['sid'] ) ? absint( $_REQUEST['sid'] ) : 0; $key = isset( $_REQUEST['key'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['key'] ) ) : ''; $scope = isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : 'post'; // phpcs:enable WordPress.Security.NonceVerification if ( 'confirm' === $action ) { $this->handle_confirm( $token, 'POST' === $method ); } if ( 'unsubscribe' === $action ) { $sub = $this->verify( $sid, $key ); if ( ! $sub ) { $this->page( __( 'Invalid link', 'replyping' ), '

' . esc_html__( 'This link is invalid or has expired.', 'replyping' ) . '

', 400 ); } if ( 'POST' === $method ) { $this->do_unsubscribe( $sub, $scope ); $this->page( __( 'Unsubscribed', 'replyping' ), '

' . esc_html__( 'You will no longer receive these notifications.', 'replyping' ) . '

', 200 ); } $this->page( __( 'Unsubscribe', 'replyping' ), $this->unsubscribe_form( $sub ), 200 ); } } /** * Confirm a pending subscription. * * GET only shows a button; POST changes state. * * @param string $token Raw token. * @param bool $post Whether this is a POST request. */ private function handle_confirm( $token, $post ) { $sub = '' !== $token ? Db::get_by_token( $token ) : null; if ( ! $sub || 'unsubscribed' === $sub->status ) { $this->page( __( 'Invalid link', 'replyping' ), '

' . esc_html__( 'This link is invalid or has expired.', 'replyping' ) . '

', 400 ); } if ( ! $post ) { $form = $this->post_line( (int) $sub->post_id, true ); $form .= '
'; $form .= ''; $form .= ''; $form .= '

'; $this->page( __( 'Confirm subscription', 'replyping' ), $form, 200 ); } if ( 'pending' === $sub->status ) { Db::update_status( $sub->id, 'active' ); do_action( 'replyping_confirmed', Db::get( $sub->id ) ); } $this->page( __( 'Subscription confirmed', 'replyping' ), '

' . esc_html__( 'Thank you. Your subscription is confirmed.', 'replyping' ) . '

', 200 ); } /** * Escaped intro line naming the post (no email shown). * * @param int $post_id Post ID. * @param bool $confirm True for the confirm page, false for unsubscribe. * @return string */ private function post_line( $post_id, $confirm ) { $post = get_post( $post_id ); if ( ! $post ) { return ''; } $title = esc_html( wp_strip_all_tags( get_the_title( $post ) ) ); $link = '' . $title . ''; if ( $confirm ) { /* translators: %s: linked post title. */ $text = esc_html__( 'Confirm that you want email notifications for new comments on ā€œ%sā€.', 'replyping' ); } else { /* translators: %s: linked post title. */ $text = esc_html__( 'Stop email notifications for ā€œ%sā€?', 'replyping' ); } return '

' . sprintf( $text, $link ) . '

'; } /** * Build the unsubscribe confirmation form (POST). * * @param object $sub Subscription. * @return string */ private function unsubscribe_form( $sub ) { $html = $this->post_line( (int) $sub->post_id, false ); $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= '

'; $html .= '

'; $html .= '

'; $html .= '
'; return $html; } /** * Output a simple page and exit. Body HTML must already be escaped. * * @param string $title Title. * @param string $html Pre-escaped body HTML. * @param int $status HTTP status. */ private function page( $title, $html, $status ) { nocache_headers(); if ( ! headers_sent() ) { header( 'Referrer-Policy: no-referrer' ); } wp_die( $html, esc_html( $title ), array( 'response' => (int) $status ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } }