[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'; $routes = array( array( '/admin/discover', 'GET', 'discover' ), array( '/admin/relays', 'POST', 'start' ), array( $relay . '/upload', 'POST', 'upload' ), array( $relay . '/plan', 'GET', 'plan' ), array( $relay . '/apply', 'POST', 'apply' ), array( $relay, 'DELETE', 'abort' ), array( $relay . '/rollback', 'POST', 'rollback' ), array( $relay . '/verify', 'POST', 'verify' ), ); foreach ( $routes as $route ) { register_rest_route( Client::REST_NAMESPACE, $route[0], array( 'methods' => $route[1], 'callback' => array( $this, $route[2] ), 'permission_callback' => array( $this, 'permission' ), ) ); } } /** * Administrators only (D12). */ public function permission(): bool { return current_user_can( 'manage_options' ); } /** * `GET /admin/discover?post=` — what a relay of the page would carry. * * @param \WP_REST_Request $request Request. */ public function discover( \WP_REST_Request $request ): \WP_REST_Response { $post_id = absint( $request->get_param( 'post' ) ); if ( ! current_user_can( 'edit_post', $post_id ) ) { return self::error( 'forbidden', __( 'You cannot edit that content.', 'content-relay' ), 403 ); } try { $manifest = ( new Exporter( new Export_Context( Adapter_Factory::create() ) ) )->export( $post_id ); } catch ( Export_Exception $e ) { return self::error( 'export_failed', html_entity_decode( $e->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 400 ); } $objects = array(); $files = array(); $bytes = 0; foreach ( (array) $manifest['objects'] as $key => $record ) { $file_bytes = (int) ( $record['file']['bytes'] ?? 0 ); $sha1 = (string) ( $record['file']['sha1'] ?? '' ); if ( '' !== $sha1 && ! isset( $files[ $sha1 ] ) ) { $files[ $sha1 ] = true; $bytes += $file_bytes; } $objects[] = array( 'key' => (string) $key, 'kind' => (string) $record['kind'], 'role' => (string) $record['role'], 'language' => $record['language'] ?? null, 'label' => self::describe( $record ), 'bytes' => $file_bytes, 'relations' => implode( ', ', (array) ( $record['relations'] ?? array() ) ), ); } $roots = array(); foreach ( (array) $manifest['roots'] as $key ) { $record = (array) $manifest['objects'][ $key ]; $roots[] = array( 'key' => (string) $key, 'language' => $record['language'] ?? null, 'title' => (string) ( $record['title'] ?? '' ), 'status' => (string) ( $record['fields']['post_status'] ?? '' ), 'path' => (string) ( $record['path'] ?? '' ), ); } $requirements = array(); foreach ( (array) $manifest['requirements'] as $type => $items ) { $requirements[ (string) $type ] = count( (array) $items ); } return self::ok( array( 'roots' => $roots, 'objects' => $objects, 'files' => count( $files ), 'bytes' => $bytes, 'requirements' => $requirements, 'warnings' => array_values( (array) $manifest['warnings'] ), ) ); } /** * `POST /admin/relays {post, connection, overwrite}` — opens a session on the receiver. * * @param \WP_REST_Request $request Request. */ public function start( \WP_REST_Request $request ): \WP_REST_Response { $post_id = absint( $request->get_param( 'post' ) ); if ( ! current_user_can( 'edit_post', $post_id ) ) { return self::error( 'forbidden', __( 'You cannot edit that content.', 'content-relay' ), 403 ); } $label = sanitize_key( (string) $request->get_param( 'connection' ) ); $overwrite = self::flag( $request->get_param( 'overwrite' ) ); return self::run( static function ( Sender $sender ) use ( $post_id, $label, $overwrite ): array { return $sender->start( $post_id, $label, $overwrite ); } ); } /** * `POST /admin/relays/{session}/upload` — one time-boxed upload step. * * @param \WP_REST_Request $request Request. */ public function upload( \WP_REST_Request $request ): \WP_REST_Response { $session = (string) $request['session']; return self::run( static function ( Sender $sender ) use ( $session ): array { return $sender->upload( $session, self::UPLOAD_BUDGET_SECONDS ); } ); } /** * `GET /admin/relays/{session}/plan[?overwrite=1]` — the receiver's plan with diffs. * * @param \WP_REST_Request $request Request. */ public function plan( \WP_REST_Request $request ): \WP_REST_Response { $session = (string) $request['session']; $overwrite = null === $request->get_param( 'overwrite' ) ? null : self::flag( $request->get_param( 'overwrite' ) ); return self::run( static function ( Sender $sender ) use ( $session, $overwrite ): array { return $sender->plan( $session, $overwrite ); } ); } /** * `POST /admin/relays/{session}/apply {plan_hash, overwrite, flush_cache}` — applies the approved plan. * * @param \WP_REST_Request $request Request. */ public function apply( \WP_REST_Request $request ): \WP_REST_Response { $session = (string) $request['session']; $hash = (string) $request->get_param( 'plan_hash' ); $overwrite = self::flag( $request->get_param( 'overwrite' ) ); $flush = self::flag( $request->get_param( 'flush_cache' ) ); return self::run( static function ( Sender $sender ) use ( $session, $hash, $overwrite, $flush ): array { return $sender->apply( $session, $hash, $overwrite, $flush ); } ); } /** * `POST /admin/relays/{session}/verify` — checks the relayed pages over HTTP on the receiver. * * @param \WP_REST_Request $request Request. */ public function verify( \WP_REST_Request $request ): \WP_REST_Response { $session = (string) $request['session']; return self::run( static function ( Sender $sender ) use ( $session ): array { return $sender->verify( $session ); } ); } /** * `DELETE /admin/relays/{session}` — cancels an unapplied relay. * * @param \WP_REST_Request $request Request. */ public function abort( \WP_REST_Request $request ): \WP_REST_Response { $session = (string) $request['session']; return self::run( static function ( Sender $sender ) use ( $session ): array { $sender->abort( $session ); return array( 'session' => $session ); } ); } /** * `POST /admin/relays/{session}/rollback {overwrite}` — rolls back an applied relay on the receiver. * * @param \WP_REST_Request $request Request. */ public function rollback( \WP_REST_Request $request ): \WP_REST_Response { $session = (string) $request['session']; $overwrite = self::flag( $request->get_param( 'overwrite' ) ); return self::run( static function ( Sender $sender ) use ( $session, $overwrite ): array { return $sender->rollback( $session, $overwrite ); } ); } /** * A boolean request value (JSON true/false, or `1`/`0`/`true`/`false` in a query string). * * @param mixed $value Raw value. */ private static function flag( $value ): bool { if ( is_bool( $value ) ) { return $value; } return is_scalar( $value ) && in_array( strtolower( (string) $value ), array( '1', 'true', 'yes', 'on' ), true ); } /** * Runs a Sender step and turns its outcome into a response. * * @param callable $step Step. * * @phpstan-param callable(Sender): array $step */ private static function run( callable $step ): \WP_REST_Response { try { return self::ok( $step( Sender::for_site() ) ); } catch ( Send_Exception $e ) { return self::error( $e->error_code(), $e->plain_message(), $e->status() ); } } /** * One-line description of a manifest object. * * @param array $record Object. */ private static function describe( array $record ): string { switch ( $record['kind'] ) { case 'attachment': return isset( $record['file']['relative'] ) ? (string) $record['file']['relative'] : (string) ( $record['title'] ?? '' ); case 'term': return $record['taxonomy'] . '/' . $record['slug']; case 'user': return (string) $record['login']; default: return $record['post_type'] . ' /' . $record['path'] . '/'; } } /** * Success response. * * @param array $data Data. */ private static function ok( array $data ): \WP_REST_Response { return new \WP_REST_Response( array( 'ok' => true, 'code' => 'ok', 'message' => '', 'data' => $data, ) ); } /** * Error response. apiFetch rejects with this body, so `message` reaches the screen. * * @param string $code Code. * @param string $message Message (plain text). * @param int $status HTTP status. */ private static function error( string $code, string $message, int $status ): \WP_REST_Response { return new \WP_REST_Response( array( 'ok' => false, 'code' => $code, 'message' => $message, 'data' => array( 'status' => $status ), ), $status ); } }