'GET', 'callback' => [ $this, 'tables' ], 'permission_callback' => [ $this->permission, 'require_pull_or_admin' ], 'args' => [ 'include_uploads_size' => [ 'required' => false, 'type' => 'boolean', 'default' => false, ], 'include_scannables' => [ 'required' => false, 'type' => 'boolean', 'default' => false, ], ], ] ); register_rest_route( 'shiftora/v1', '/export/chunk', [ 'methods' => 'GET', 'callback' => [ $this, 'chunk' ], 'permission_callback' => [ $this->permission, 'require_pull' ], 'args' => [ 'table' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'offset' => [ 'required' => false, 'type' => 'integer', 'default' => 0, 'minimum' => 0, ], 'chunk_size' => [ 'required' => false, 'type' => 'integer', 'default' => DatabaseExporter::DEFAULT_CHUNK_SIZE, 'minimum' => 1, 'maximum' => DatabaseExporter::MAX_CHUNK_SIZE, ], 'last_pk' => [ 'required' => false, 'type' => 'string', 'default' => null, 'sanitize_callback' => 'sanitize_text_field', ], ], ] ); register_rest_route( 'shiftora/v1', '/export/pull-sql', [ 'methods' => 'POST', 'callback' => [ $this, 'pull_sql' ], 'permission_callback' => [ $this->permission, 'require_pull' ], ] ); register_rest_route( 'shiftora/v1', '/export/verify-auth', [ 'methods' => 'GET', 'callback' => [ $this, 'verify_auth' ], 'permission_callback' => [ $this->permission, 'require_pull' ], ] ); } /** * On a network the table listing interleaves every blog's tables, so callers need * this to plan. The prefix is reported because callers compose table names from it. * It is `base_prefix`, not `prefix`: on a subsite `prefix` is the subsite's own. * * @return array{is_multisite: bool, site_id: int, blog_ids: array, prefix: string} */ private function shape(): array { global $wpdb; $prefix = (string) ( $wpdb->base_prefix ?? ( $wpdb->prefix ?? 'wp_' ) ); if ( ! is_multisite() ) { return [ 'is_multisite' => false, 'site_id' => 1, 'blog_ids' => [ 1 ], 'prefix' => $prefix, ]; } $blog_ids = []; foreach ( (array) get_sites( [ 'number' => 0, 'fields' => 'ids' ] ) as $blog_id ) { $blog_ids[] = (int) $blog_id; } return [ 'is_multisite' => true, 'site_id' => (int) get_current_network_id(), 'blog_ids' => $blog_ids, 'prefix' => $prefix, ]; } public function tables( WP_REST_Request $request ): WP_REST_Response { $tables = $this->exporter->get_wp_tables(); $row_counts = $this->exporter->row_counts( $tables ); $table_sizes = $this->exporter->table_sizes( $tables ); $response_data = [ 'shape' => $this->shape(), 'tables' => array_map( fn( $t ) => [ 'full_name' => $t, 'bare_name' => $this->exporter->strip_prefix( $t ), 'row_count' => $row_counts[ $t ] ?? 0, 'size_bytes' => $table_sizes[ $t ] ?? 0, ], $tables ), 'total_size_bytes' => array_sum( $table_sizes ), ]; // The recursive uploads scan is expensive on large directories, so it is opt-in. $include_uploads_size = (bool) $request->get_param( 'include_uploads_size' ); $include_scannables = (bool) $request->get_param( 'include_scannables' ); if ( $include_uploads_size || $include_scannables ) { $scanner = new SiteScanner(); $scan = $scanner->scan(); if ( $include_uploads_size ) { $response_data['uploads_size_bytes'] = $scan['uploads_size_bytes']; } // Whatever else a registered file stage reports rides along with the uploads size. if ( $include_scannables ) { $response_data = array_merge( $scan, $response_data ); } } return new WP_REST_Response( $response_data, 200 ); } public function chunk( WP_REST_Request $request ): WP_REST_Response|WP_Error { $table = (string) $request->get_param( 'table' ); $offset = (int) $request->get_param( 'offset' ); $chunk_size = (int) $request->get_param( 'chunk_size' ); $raw_lp = $request->get_param( 'last_pk' ); // An empty string means the first chunk (no WHERE). $last_pk = ( null !== $raw_lp && '' !== $raw_lp ) ? (string) $raw_lp : null; // Accepts `wp_options` or `options`; normalized so validation sees one form. global $wpdb; $table = $wpdb->prefix . $this->exporter->strip_prefix( $table ); // The plugin's own tables hold install-specific keys and connection tokens. $bare = $this->exporter->strip_prefix( $table ); if ( str_starts_with( $bare, 'shiftora_' ) ) { return new WP_Error( 'shiftora_forbidden', __( 'This table cannot be exported.', 'shiftora' ), [ 'status' => 403 ] ); } $valid_tables = $this->exporter->get_wp_tables(); if ( ! in_array( $table, $valid_tables, true ) ) { return new WP_Error( 'shiftora_invalid_table', __( 'Invalid or inaccessible table name.', 'shiftora' ), [ 'status' => 400 ] ); } try { $chunk = $this->exporter->export_chunk( $table, $offset, $chunk_size, $last_pk ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_export_error', $e->getMessage(), [ 'status' => 500 ] ); } return new WP_REST_Response( $chunk, 200 ); } /** Fast path for pulls: a gzipped raw-SQL chunk with find/replace applied. */ public function pull_sql( WP_REST_Request $request ): WP_REST_Response|WP_Error { $body = $request->get_json_params(); if ( empty( $body ) || ! is_array( $body ) ) { return new WP_Error( 'shiftora_invalid_body', __( 'Request body must be valid JSON.', 'shiftora' ), [ 'status' => 400 ] ); } $chunk_size = isset( $body['chunk_size'] ) ? (int) $body['chunk_size'] : null; $find_replace = is_array( $body['find_replace'] ?? null ) ? $body['find_replace'] : []; if ( is_array( $body['tables'] ?? null ) ) { return $this->pull_sql_batch( $body, $chunk_size, $find_replace ); } $table = $this->resolve_pull_table( (string) ( $body['table'] ?? '' ) ); $raw_lp = $body['last_pk'] ?? null; $last_pk = ( null !== $raw_lp && '' !== $raw_lp ) ? (string) $raw_lp : null; if ( $table instanceof WP_Error ) { return $table; } try { $chunk = $this->exporter->export_sql_chunk( $table, RuleSet::from_array( $find_replace ), (int) ( $body['offset'] ?? 0 ), $chunk_size, $last_pk ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_export_error', $e->getMessage(), [ 'status' => 500 ] ); } return new WP_REST_Response( $chunk, 200 ); } /** * Several tables in one response. Each request costs a full WordPress boot on this * site, which dominates when most tables are small. * * Walks the cursors in order, following a table into its next chunk, and stops at * the byte or time budget. The caller imports whatever came back and asks again. * * @param array $body * @param array $find_replace */ private function pull_sql_batch( array $body, ?int $chunk_size, array $find_replace ): WP_REST_Response|WP_Error { global $wpdb; // One session change for the whole batch rather than one per chunk. try { $sql_mode_before = SessionSqlMode::relax( $wpdb ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_export_error', $e->getMessage(), [ 'status' => 500 ] ); } try { return $this->walk_batch( $body, $chunk_size, $find_replace ); } finally { SessionSqlMode::restore( $wpdb, $sql_mode_before ); } } /** * @param array $body * @param array $find_replace */ private function walk_batch( array $body, ?int $chunk_size, array $find_replace ): WP_REST_Response|WP_Error { $started = microtime( true ); $max_bytes = max( 256 * 1024, min( 16 * 1024 * 1024, (int) ( $body['max_bytes'] ?? 4 * 1024 * 1024 ) ) ); $max_ms = max( 500, min( 25000, (int) ( $body['max_ms'] ?? 6000 ) ) ); $rule_set = RuleSet::from_array( $find_replace ); $batch = []; $bytes = 0; foreach ( array_slice( $body['tables'], 0, 200 ) as $cursor ) { if ( ! is_array( $cursor ) ) { continue; } $requested = (string) ( $cursor['table'] ?? '' ); $table = $this->resolve_pull_table( $requested ); if ( $table instanceof WP_Error ) { // Hand back what is done; the caller reports the error when it asks for this table itself. return [] === $batch ? $table : new WP_REST_Response( [ 'batch' => $batch ], 200 ); } $offset = (int) ( $cursor['offset'] ?? 0 ); $raw_lp = $cursor['last_pk'] ?? null; $last_pk = ( null !== $raw_lp && '' !== $raw_lp ) ? (string) $raw_lp : null; do { try { $chunk = $this->exporter->export_sql_chunk( $table, $rule_set, $offset, $chunk_size, $last_pk ); } catch ( \RuntimeException $e ) { return [] === $batch ? new WP_Error( 'shiftora_export_error', $e->getMessage(), [ 'status' => 500 ] ) : new WP_REST_Response( [ 'batch' => $batch ], 200 ); } $chunk['request_table'] = $requested; $chunk['request_offset'] = $offset; $chunk['request_last_pk'] = $last_pk; $batch[] = $chunk; $bytes += strlen( (string) ( $chunk['sql_gz'] ?? '' ) ); $out_of_budget = $bytes >= $max_bytes || ( microtime( true ) - $started ) * 1000 >= $max_ms || RequestBudget::exhausted(); if ( $out_of_budget ) { return new WP_REST_Response( [ 'batch' => $batch ], 200 ); } // Resume exactly as the caller's stored cursor would: a keyed table restarts // at offset 0, and the offset is part of the page identity the importer // uses to recognise a page it already applied. $last_pk = isset( $chunk['last_pk'] ) && null !== $chunk['last_pk'] ? (string) $chunk['last_pk'] : null; $offset = null !== $last_pk ? 0 : (int) ( $chunk['next_offset'] ?? 0 ); } while ( ! empty( $chunk['has_more'] ) ); } return new WP_REST_Response( [ 'batch' => $batch ], 200 ); } /** The prefixed table name, or an error when it is missing, internal or not a table of this site. */ private function resolve_pull_table( string $table ): string|WP_Error { if ( '' === $table ) { return new WP_Error( 'shiftora_invalid_params', __( 'table is required.', 'shiftora' ), [ 'status' => 400 ] ); } global $wpdb; $bare = $this->exporter->strip_prefix( $table ); if ( str_starts_with( $bare, 'shiftora_' ) ) { return new WP_Error( 'shiftora_forbidden', __( 'This table cannot be exported.', 'shiftora' ), [ 'status' => 403 ] ); } $table = $wpdb->prefix . $bare; $this->pull_tables ??= $this->exporter->get_wp_tables(); if ( ! in_array( $table, $this->pull_tables, true ) ) { return new WP_Error( 'shiftora_invalid_table', __( 'Invalid or inaccessible table name.', 'shiftora' ), [ 'status' => 400 ] ); } return $table; } public function verify_auth( WP_REST_Request $request ): WP_REST_Response { global $wpdb; return new WP_REST_Response( [ 'ok' => true, 'auth' => 'bearer-token', 'shiftora_version' => SHIFTORA_VERSION, 'capabilities' => array_merge( ServerCapabilities::probe( $wpdb ), [ ResponseSignature::CAPABILITY => true, 'accepts_key_id' => true ] ), ], 200 ); } }