'GET', 'callback' => [ $this, 'manifest' ], 'permission_callback' => [ $this->permission, 'require_pull' ], 'args' => [ 'stage' => [ 'required' => false, 'type' => 'string', 'default' => 'uploads', 'enum' => FileStages::keys(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_text_field', ], ], ] ); register_rest_route( 'shiftora/v1', '/export/media-stream', [ 'methods' => 'GET', 'callback' => [ $this, 'stream' ], 'permission_callback' => [ $this->permission, 'require_pull' ], 'args' => [ 'stage' => [ 'required' => false, 'type' => 'string', 'default' => 'uploads', 'enum' => FileStages::keys(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_text_field', ], 'file' => [ 'required' => true, 'type' => 'string', // Kept verbatim: this names a file on disk. VirtualPath is the shared path rule. 'sanitize_callback' => [ VirtualPath::class, 'is_usable' ], ], 'offset' => [ 'required' => false, 'type' => 'integer', 'default' => 0, 'minimum' => 0, ], 'chunk_size' => [ 'required' => false, 'type' => 'integer', 'default' => FileExporter::DEFAULT_CHUNK_SIZE, 'minimum' => 1, 'maximum' => 10_485_760, // 10 MiB hard cap ], ], ] ); } /** `rest_pre_serve_request` hook: takes over only for a streamed chunk, otherwise passes $served through. */ public function write_chunk_body( $served, $result, $server = null, $request = null ): bool { if ( ! $result instanceof StreamedChunkResponse ) { return (bool) $served; } // phpcs:disable WordPress.WP.AlternativeFunctions -- Streaming needs the native output functions. while ( ob_get_level() > 0 ) { ob_end_flush(); } header( 'Content-Type: application/json; charset=utf-8' ); FileBatchExportEndpoint::no_transform_headers(); // The length is unknown until the chunk is read, and a wrong one truncates it. header_remove( 'Content-Length' ); foreach ( $result->body_fragments() as $fragment ) { echo $fragment; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- A fragment of the JSON response body, not HTML. flush(); } // phpcs:enable return true; } public function manifest( WP_REST_Request $request ): WP_REST_Response|WP_Error { $stage = (string) $request->get_param( 'stage' ); $connection = $request->get_param( '_shiftora_connection' ); $conn_id = ( is_object( $connection ) && isset( $connection->id ) ) ? (int) $connection->id : 0; // A manifest walks the whole stage and digests every file, which can outlast the // destination's window, so a served one is cached and retries don't repeat the walk. // The cache is here, not in StageExportScope::manifest(), because only the endpoint // knows the connection to key it by; manifest() stays a pure walk so // allowed_paths() can recompute on a miss. if ( $conn_id > 0 ) { $cached = StageExportScope::cached_manifest( $conn_id, $stage ); if ( null !== $cached ) { return new WP_REST_Response( [ 'files' => $cached, 'count' => count( $cached ), ], 200 ); } } $files = StageExportScope::manifest( $stage ); // The stream and batch routes validate against this, and recompute it if gone. if ( $conn_id > 0 ) { StageExportScope::remember_manifest( $conn_id, $stage, $files ); StageExportScope::remember( $conn_id, $stage, $files ); } return new WP_REST_Response( [ 'files' => $files, 'count' => count( $files ), ], 200 ); } /** * The body streams as it is encoded: a returned array would hold several copies of * a 10 MiB chunk, enough to exhaust a 128 MB host. The digest is computed in a * first pass so the envelope can lead the payload; the receiver needs it first. */ public function stream( WP_REST_Request $request ): WP_REST_Response|WP_Error { $stage = (string) $request->get_param( 'stage' ); $file = (string) $request->get_param( 'file' ); $offset = (int) $request->get_param( 'offset' ); $chunk_size = (int) $request->get_param( 'chunk_size' ); // The path must be one this pull may read; a cache miss recomputes the scope rather than widening it. $connection = $request->get_param( '_shiftora_connection' ); if ( $connection && isset( $connection->id ) ) { $allowed_paths = StageExportScope::allowed_paths( (int) $connection->id, $stage ); if ( ! isset( $allowed_paths[ $file ] ) ) { return new WP_Error( 'shiftora_file_not_allowed', 'The requested file is not in the manifest served for this migration stage.', [ 'status' => 403 ] ); } } try { $exporter = StageExportScope::exporter( $stage ); // Described, not read: stream_chunk() would build the payload in memory first. $described = $exporter->describe_chunk( $file, $offset, $chunk_size ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_file_error', $e->getMessage(), [ 'status' => 422 ] ); } return new StreamedChunkResponse( $exporter, $file, $offset, $chunk_size, $described ); } }