service = $service; } public function register_routes(): void { register_rest_route( RunController::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})/review-preview', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'preview' ), 'permission_callback' => array( $this, 'authorize' ), 'args' => $this->args( false ), ) ); register_rest_route( RunController::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})/review-request', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'submit' ), 'permission_callback' => array( $this, 'authorize' ), 'args' => $this->args( true ), ) ); } /** * @param WP_REST_Request> $request Request. * @return WP_REST_Response|WP_Error */ public function preview( WP_REST_Request $request ) { try { $result = $this->service->preview( (string) $request->get_param( 'uuid' ), (string) $request->get_param( 'scope' ), $this->selected_refs( $request ), (string) $request->get_param( 'idempotency_key' ), (string) $request->get_param( 'name' ), (string) $request->get_param( 'email' ), (string) $request->get_param( 'message' ) ); } catch ( InvalidArgumentException $exception ) { return $this->error( 'invalid', 400 ); } catch ( StorageException $exception ) { return $this->error( 'not_found', 404 ); } catch ( \Throwable $exception ) { return $this->error( 'failed', 500 ); } return new WP_REST_Response( array_merge( $result, array( 'disclosure_version' => ReviewPayloadBuilder::DISCLOSURE_VERSION, 'recipient' => 'wp-content.ru', 'purpose' => 'request_review_and_project_reply', 'retention_days' => 90, 'terms_url' => 'https://wp-content.ru/terms/', 'privacy_url' => 'https://wp-content.ru/privacy/', 'consent_url' => 'https://wp-content.ru/personal-data-consent/', ) ), 200 ); } /** * @param WP_REST_Request> $request Request. * @return WP_REST_Response|WP_Error */ public function submit( WP_REST_Request $request ) { if ( true !== $request->get_param( 'confirmed' ) || true !== $request->get_param( 'consent' ) || ReviewPayloadBuilder::DISCLOSURE_VERSION !== (string) $request->get_param( 'disclosure_version' ) ) { return $this->error( 'confirmation', 409 ); } try { $result = $this->service->submit( (string) $request->get_param( 'uuid' ), (string) $request->get_param( 'scope' ), $this->selected_refs( $request ), (string) $request->get_param( 'idempotency_key' ), (string) $request->get_param( 'name' ), (string) $request->get_param( 'email' ), (string) $request->get_param( 'message' ), (string) $request->get_param( 'preview_digest' ), get_current_user_id() ); } catch ( InvalidArgumentException $exception ) { return $this->error( 'confirmation', 409 ); } catch ( StorageException $exception ) { return $this->error( 'not_found', 404 ); } catch ( \Throwable $exception ) { return $this->error( 'delivery', 502 ); } return new WP_REST_Response( $result, 201 ); } /** * @param WP_REST_Request> $request Request. * @return true|WP_Error */ public function authorize( WP_REST_Request $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'px320_audit_forbidden', __( 'You are not allowed to manage site audits.', '320px-site-audit' ), array( 'status' => 403 ) ); } $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! is_string( $nonce ) || false === wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error( 'px320_audit_invalid_nonce', __( 'The audit request could not be verified. Refresh the page and try again.', '320px-site-audit' ), array( 'status' => 403 ) ); } return true; } /** * @return array> */ private function args( bool $submission ): array { $args = array( 'scope' => array( 'type' => 'string', 'enum' => array( 'contact', 'summary', 'selected', 'full' ), 'required' => true, ), 'selected_finding_refs' => array( 'type' => 'array', 'maxItems' => ReviewPayloadBuilder::MAX_SELECTED, 'default' => array(), 'items' => array( 'type' => 'string', 'maxLength' => 36, ), ), 'idempotency_key' => array( 'type' => 'string', 'maxLength' => 36, 'required' => true, ), 'name' => array( 'type' => 'string', 'maxLength' => 100, 'required' => true, ), 'email' => array( 'type' => 'string', 'format' => 'email', 'maxLength' => 254, 'required' => true, ), 'message' => array( 'type' => 'string', 'minLength' => 20, 'maxLength' => 4000, 'required' => true, ), ); if ( $submission ) { $args['preview_digest'] = array( 'type' => 'string', 'minLength' => 64, 'maxLength' => 64, 'required' => true, ); $args['confirmed'] = array( 'type' => 'boolean', 'required' => true, ); $args['consent'] = array( 'type' => 'boolean', 'required' => true, ); $args['disclosure_version'] = array( 'type' => 'string', 'maxLength' => 24, 'required' => true, ); } return $args; } /** * @param WP_REST_Request> $request Request. * @return list */ private function selected_refs( WP_REST_Request $request ): array { $value = $request->get_param( 'selected_finding_refs' ); if ( ! is_array( $value ) ) { return array(); } foreach ( $value as $reference ) { if ( ! is_string( $reference ) ) { throw new InvalidArgumentException( 'review_finding_selection_invalid' ); } } return array_values( $value ); } private function error( string $reason, int $status ): WP_Error { $messages = array( 'invalid' => __( 'The local preview could not be prepared. Check the contact fields and selected report scope. Nothing was sent.', '320px-site-audit' ), 'not_found' => __( 'This completed report is no longer available. Nothing was sent.', '320px-site-audit' ), 'confirmation' => __( 'The preview or confirmation changed. Prepare the exact preview again; nothing was sent.', '320px-site-audit' ), 'delivery' => __( 'The one-time request could not be confirmed by wp-content.ru. It will not retry in the background.', '320px-site-audit' ), 'failed' => __( 'The local preview failed safely. Nothing was sent.', '320px-site-audit' ), ); return new WP_Error( 'px320_audit_review_' . $reason, $messages[ $reason ] ?? $messages['failed'], array( 'status' => $status ) ); } }