run( $env, $key, false ), 'application/octet-stream' ); } /** * Runs an operation in this process (a site backing itself up) and returns the framed response. * * @param array $env Envelope (v, kid, ts, nonce, op, args, budget). * @param array $key Key record with secret_raw. * @return string */ public static function handle_local( array $env, array $key ) { $api = new self(); return $api->run( $env, $key, true ); } /** * Runs one operation and returns the framed response body. * * @param array $env Envelope. * @param array $key Key. * @param bool $local Called in-process (no HTTP request of its own). * @return string */ private function run( array $env, array $key, $local ) { $this->start = ( ! $local && isset( $_SERVER['REQUEST_TIME_FLOAT'] ) ) ? (float) $_SERVER['REQUEST_TIME_FLOAT'] : microtime( true ); $budget = isset( $env['budget'] ) && is_array( $env['budget'] ) ? $env['budget'] : array(); $ms = isset( $budget['ms'] ) ? (int) $budget['ms'] : (int) Settings::get( 'remote_ms' ); $ms = max( 1000, min( 60000, $ms ) ); $max_exec = Environment::max_execution_time(); $this->orig_max_exec = $max_exec; if ( $max_exec > 0 ) { $ms = min( $ms, max( 1000, ( $max_exec - 5 ) * 1000 ) ); } $this->deadline = $this->start + $ms / 1000; $mem_cap = Environment::memory_limit(); $bytes = isset( $budget['bytes'] ) ? (int) $budget['bytes'] : (int) Settings::get( 'remote_bytes' ); $bytes = max( 262144, min( 33554432, $bytes ) ); $this->bytes = $mem_cap > 0 ? min( $bytes, (int) ( $mem_cap / 6 ) ) : $bytes; $secret = $key['secret_raw']; $this->enc = Frame_Codec::encoder( Signer::enc_key( $secret ), Signer::sign_key( $secret ), (string) $env['nonce'], Secrets::has_gcm() ); if ( ! $local ) { @set_time_limit( (int) ceil( $ms / 1000 ) + 30 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- Room to build one answer within the budget. ignore_user_abort( true ); } $op = (string) $env['op']; $args = isset( $env['args'] ) && is_array( $env['args'] ) ? $env['args'] : array(); try { if ( ! in_array( $op, self::OPS, true ) ) { throw new \InvalidArgumentException( 'Unknown operation.' ); } $end = $this->{'op_' . $op}( $args, $key ); } catch ( \Throwable $e ) { $message = $e instanceof Db_Exception ? 'Database error: ' . $e->getMessage() : $e->getMessage(); Frame_Codec::add_json( $this->enc, 'X', array( 'error' => $message ) ); Logger::audit( 'Operation failed.', array( 'op' => $op, 'error' => $message ) ); $end = array( 'ok' => false ); } $end['ms'] = (int) round( ( microtime( true ) - $this->start ) * 1000 ); return Frame_Codec::finish( $this->enc, $end ); } /** * Site, PHP, database and limit facts. * * @param array $args Args. * @param array $key Key. */ private function op_hello( array $args, array $key ) { $facts = Environment::facts(); $facts['php']['max_execution_time'] = $this->orig_max_exec; $facts['roots'] = array(); foreach ( Roots::all() as $root_key => $dir ) { $facts['roots'][ $root_key ] = wp_normalize_path( $dir ); } $facts['disk_free'] = Environment::can_call( 'disk_free_space' ) ? @disk_free_space( ABSPATH ) : null; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $facts['budget'] = array( 'ms' => (int) round( ( $this->deadline - $this->start ) * 1000 ), 'bytes' => $this->bytes, ); $facts['slots'] = Request_Guard::SLOTS; $facts['ops'] = self::OPS; // Clients use newer operations (db_values) only when listed here. $facts['endpoints'] = Connection_Keys::endpoints(); $facts['key'] = array( 'kid' => $key['kid'], 'label' => $key['label'], 'expires' => $key['expires'], ); Logger::audit( 'Session started.', array( 'ip' => Request_Guard::client_ip(), 'kid' => $key['kid'] ) ); Frame_Codec::add_json( $this->enc, 'H', $facts ); return array( 'ok' => true ); } /** * Table list with schema, a page at a time. * * @param array $args {offset}. */ private function op_db_tables( array $args ) { global $wpdb; $db = Connection::wp(); $reader = new Schema_Reader( $db ); $tables = $reader->tables( $wpdb->base_prefix ); $statuses = $reader->statuses(); $names = array_keys( $tables ); $offset = max( 0, (int) ( $args['offset'] ?? 0 ) ); $page = array(); $i = $offset; for ( ; $i < count( $names ); $i++ ) { $name = $names[ $i ]; $type = $tables[ $name ]; $columns = 'VIEW' === $type ? array() : $reader->columns( $name ); $page[] = array( 'name' => $name, 'type' => $type, 'status' => $statuses[ $name ] ?? array(), 'create' => $reader->create_sql( $name, $type ), 'columns' => $columns, 'key' => 'VIEW' === $type ? array( 'kind' => 'none', 'columns' => array() ) : $reader->paging_key( $name, $columns ), ); if ( count( $page ) >= 40 || microtime( true ) >= $this->deadline ) { ++$i; break; } } $header = array( 'tables' => $page, 'next' => $i < count( $names ) ? $i : null, 'total' => count( $names ), ); if ( 0 === $offset ) { $header['triggers'] = $reader->triggers( $wpdb->base_prefix ); $header['charset'] = $wpdb->charset; $header['collate'] = $wpdb->collate; $header['excluded_options'] = self::excluded_option_patterns(); } Frame_Codec::add_json( $this->enc, 'H', $header ); return array( 'ok' => true ); } /** * One chunk of a table as gzipped SQL. * * @param array $args {table, cursor}. * @throws \InvalidArgumentException For unknown tables. */ private function op_db_chunk( array $args ) { global $wpdb; $table = (string) ( $args['table'] ?? '' ); $db = Connection::wp(); $reader = new Schema_Reader( $db ); $tables = $reader->tables( $wpdb->base_prefix ); if ( ! isset( $tables[ $table ] ) || 'BASE TABLE' !== $tables[ $table ] ) { throw new \InvalidArgumentException( 'Unknown table.' ); } $columns = $reader->columns( $table ); $key = $reader->paging_key( $table, $columns ); $statuses = $reader->statuses(); $avg_row = isset( $statuses[ $table ] ) ? (int) $statuses[ $table ]['avg_row'] : 0; $this->session( $db ); $this->snapshot( $db, true ); $where = $table === $wpdb->options ? $this->options_filter() : array(); $exporter = new Table_Exporter( $db, (int) Settings::get( 'stmt_bytes' ) ); $cursor = isset( $args['cursor'] ) && is_array( $args['cursor'] ) ? $args['cursor'] : null; $chunk = $exporter->chunk( $table, $columns, $key, $cursor, $this->bytes, $this->deadline, $where, $avg_row ); $this->snapshot( $db, false ); $gz = gzencode( $chunk['sql'], 3 ); Frame_Codec::add_json( $this->enc, 'H', array( 'table' => $table, 'rows' => $chunk['rows'], 'next' => $chunk['next'], 'first' => $chunk['first'], 'last' => $chunk['last'], 'key' => $key, 'bigs' => $chunk['bigs'], 'sql_bytes' => strlen( $chunk['sql'] ), 'gz_bytes' => strlen( $gz ), 'sha256' => hash( 'sha256', $gz ), ) ); Frame_Codec::add( $this->enc, 'S', $gz ); return array( 'ok' => true ); } /** * A byte range of one big column value (see Table_Exporter::BIG_VALUE). * * The first piece also returns the value's length and SHA-256, computed by the database, * so PHP never holds more than one piece. * * @param array $args {table, key: [...], column, offset}. * @throws \InvalidArgumentException For unknown tables, columns or keys. */ private function op_db_value( array $args ) { $table = (string) ( $args['table'] ?? '' ); $column = (string) ( $args['column'] ?? '' ); $values = isset( $args['key'] ) && is_array( $args['key'] ) ? array_values( $args['key'] ) : array(); $offset = max( 0, (int) ( $args['offset'] ?? 0 ) ); $db = Connection::wp(); $key = $this->value_table( $db, $table ); if ( ! in_array( $column, $key['names'], true ) || count( $values ) !== count( $key['columns'] ) ) { throw new \InvalidArgumentException( 'Unknown column or key.' ); } $this->session( $db ); $exporter = new Table_Exporter( $db, (int) Settings::get( 'stmt_bytes' ) ); $length = $this->value_bytes(); $sql = 'SELECT SUBSTRING(CAST(%i AS BINARY), %d, %d), OCTET_LENGTH(%i)' . ( 0 === $offset ? ', SHA2(CAST(%i AS BINARY), 256)' : '' ) . ' FROM %i WHERE '; $args = 0 === $offset ? array( $column, $offset + 1, $length, $column, $column, $table ) : array( $column, $offset + 1, $length, $column, $table ); $where = $this->value_where( $exporter, $table, $key['columns'], $values ); $found = $db->batch( $sql . $where[0] . ' LIMIT 1', array_merge( $args, $where[1] ) ); $row = $found ? $found[0] : null; if ( ! is_array( $row ) ) { Frame_Codec::add_json( $this->enc, 'H', array( 'status' => 'missing' ) ); return array( 'ok' => true ); } Frame_Codec::add_json( $this->enc, 'H', array( 'status' => 'ok', 'len' => (int) $row[1], 'offset' => $offset, 'got' => strlen( (string) $row[0] ), 'sha256' => 0 === $offset ? (string) $row[2] : null, ) ); Frame_Codec::add( $this->enc, 'D', (string) $row[0] ); return array( 'ok' => true ); } /** * Several big column values of one table in one answer, whole, up to the byte budget. * * A table with many values just over the batch cap (WooCommerce and page-builder meta) used to * cost one db_value round trip each. Here each value is read by itself, the database checks * its length first, and a value is only loaded when it fits in what is left of the budget, so * PHP never holds more than the budget. The first value is the exception: when it is bigger * than the whole budget its first piece is sent ('part'), and the client fetches the rest * with db_value. The answer stops before the first other value that doesn't fit. * * Frames: per value a 'V' header {i, status: ok|part|missing, len, sha256, got}, followed by * a 'D' frame with the bytes for ok and part. The end frame says how many were completed. * * @param array $args {table, values: [{key: [...], column}]}. * @throws \InvalidArgumentException For unknown tables, columns or keys. */ private function op_db_values( array $args ) { $table = (string) ( $args['table'] ?? '' ); $list = isset( $args['values'] ) && is_array( $args['values'] ) ? array_slice( array_values( $args['values'] ), 0, self::MAX_VALUES ) : array(); $db = Connection::wp(); $key = $this->value_table( $db, $table ); foreach ( $list as $item ) { $column = is_array( $item ) ? (string) ( $item['column'] ?? '' ) : ''; $values = is_array( $item ) && isset( $item['key'] ) && is_array( $item['key'] ) ? $item['key'] : array(); if ( ! in_array( $column, $key['names'], true ) || count( $values ) !== count( $key['columns'] ) ) { throw new \InvalidArgumentException( 'Unknown column or key.' ); } } $this->session( $db ); $exporter = new Table_Exporter( $db, (int) Settings::get( 'stmt_bytes' ) ); $limit = $this->value_bytes(); $sent = 0; $completed = 0; foreach ( $list as $i => $item ) { if ( $completed > 0 && ( $sent >= $limit || microtime( true ) >= $this->deadline ) ) { break; } $room = $limit - $sent; $column = (string) $item['column']; // Whole when it fits; the first value is cut to the budget; any other value that doesn't // fit comes back empty (the length says so) and is left for the next request. $sql = 'SELECT IF(OCTET_LENGTH(%i) <= %d, CAST(%i AS BINARY), SUBSTRING(CAST(%i AS BINARY), 1, %d)), OCTET_LENGTH(%i), SHA2(CAST(%i AS BINARY), 256) FROM %i WHERE '; $params = array( $column, $room, $column, $column, 0 === $completed ? $room : 0, $column, $column, $table ); $where = $this->value_where( $exporter, $table, $key['columns'], array_values( $item['key'] ) ); $found = $db->batch( $sql . $where[0] . ' LIMIT 1', array_merge( $params, $where[1] ) ); $row = $found ? $found[0] : null; unset( $found ); if ( ! is_array( $row ) || null === $row[1] ) { Frame_Codec::add_json( $this->enc, 'V', array( 'i' => $i, 'status' => 'missing', ) ); ++$completed; continue; } $len = (int) $row[1]; $data = (string) $row[0]; if ( $len > $room && $completed > 0 ) { break; // Next request. } Frame_Codec::add_json( $this->enc, 'V', array( 'i' => $i, 'status' => $len > $room ? 'part' : 'ok', 'len' => $len, 'got' => strlen( $data ), 'sha256' => (string) $row[2], ) ); Frame_Codec::add( $this->enc, 'D', $data ); $sent += strlen( $data ); ++$completed; unset( $row, $data ); if ( $len > $room ) { break; // The rest of this value comes with db_value. } } return array( 'ok' => true, 'completed' => $completed, ); } /** * Checks a table for db_value(s): one of this site's base tables, with a key that addresses a row. * * @param Connection $db Connection. * @param string $table Table. * @return array{columns: string[], names: string[]} Key columns and all column names. * @throws \InvalidArgumentException For unknown tables or tables without a usable key. */ private function value_table( Connection $db, $table ) { global $wpdb; $reader = new Schema_Reader( $db ); $tables = $reader->tables( $wpdb->base_prefix ); if ( ! isset( $tables[ $table ] ) || 'BASE TABLE' !== $tables[ $table ] ) { throw new \InvalidArgumentException( 'Unknown table.' ); } $columns = $reader->columns( $table ); $key = $reader->paging_key( $table, $columns ); if ( ! $key['columns'] ) { throw new \InvalidArgumentException( 'Unknown column or key.' ); } return array( 'columns' => $key['columns'], 'names' => array_column( $columns, 'name' ), ); } /** * Most bytes of values in one answer. */ private function value_bytes() { return max( 65536, min( $this->bytes, 8388608 ) ); } /** * WHERE condition for one row by its key. Secrets are left out of this read too, not only out * of the table dump. * * @param Table_Exporter $exporter Exporter. * @param string $table Table. * @param string[] $key_columns Key columns. * @param array $values Key values. * @return array{0: string, 1: array} SQL with placeholders, and their values. */ private function value_where( Table_Exporter $exporter, $table, array $key_columns, array $values ) { global $wpdb; list( $key_sql, $key_args ) = $exporter->key_condition( $key_columns, $values ); if ( $table !== $wpdb->options ) { return array( $key_sql, $key_args ); } $filter = $this->options_filter(); return array( '(' . $key_sql . ') AND (' . $filter[0] . ')', array_merge( $key_args, $filter[1] ) ); } /** * Row counts (for verification). * * @param array $args {tables}. */ private function op_db_count( array $args ) { global $wpdb; $db = Connection::wp(); $reader = new Schema_Reader( $db ); $tables = $reader->tables( $wpdb->base_prefix ); $counts = array(); foreach ( (array) ( $args['tables'] ?? array() ) as $table ) { if ( ! isset( $tables[ $table ] ) || 'BASE TABLE' !== $tables[ $table ] ) { continue; } $where = $table === $wpdb->options ? $this->options_filter() : array( '1=1', array() ); $counts[ $table ] = (int) $db->value( 'SELECT COUNT(*) FROM %i WHERE ' . $where[0], $table, ...$where[1] ); if ( microtime( true ) >= $this->deadline ) { break; } } Frame_Codec::add_json( $this->enc, 'H', array( 'counts' => $counts ) ); return array( 'ok' => true ); } /** * Directory listing page for one root. * * @param array $args {root, after|after64, max}. * @throws \InvalidArgumentException For unknown roots. */ private function op_fs_list( array $args ) { $roots = Roots::all(); $root = (string) ( $args['root'] ?? '' ); if ( ! isset( $roots[ $root ] ) ) { throw new \InvalidArgumentException( 'Unknown root.' ); } $after = isset( $args['after64'] ) ? (string) base64_decode( (string) $args['after64'], true ) : (string) ( $args['after'] ?? '' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode if ( '' !== $after && null === Fs::clean_relative( $after ) ) { throw new \InvalidArgumentException( 'Bad cursor.' ); } $max = max( 100, min( 20000, (int) ( $args['max'] ?? 5000 ) ) ); $walker = new Walker( $root, $roots[ $root ] ); $result = $walker->walk( $after, $max, $this->deadline ); $lines = ''; foreach ( $result['entries'] as $entry ) { $lines .= wp_json_encode( $entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . "\n"; } $header = array( 'root' => $root, 'count' => count( $result['entries'] ), 'excluded' => $result['excluded'], 'next' => null, ); if ( null !== $result['next'] ) { if ( preg_match( '//u', $result['next'] ) ) { $header['next'] = $result['next']; } else { $header['next64'] = base64_encode( $result['next'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } } $header['done'] = null === $result['next']; Frame_Codec::add_json( $this->enc, 'H', $header ); Frame_Codec::add( $this->enc, 'D', gzencode( $lines, 3 ) ); return array( 'ok' => true ); } /** * Several small files. Stops at the byte budget; the client asks again for the rest. * * @param array $args {root, files: [{p|p64}]}. */ private function op_fs_read_batch( array $args ) { $root = (string) ( $args['root'] ?? '' ); $files = isset( $args['files'] ) && is_array( $args['files'] ) ? $args['files'] : array(); $limit = (int) Settings::get( 'small_file' ); $sent = 0; $completed = 0; foreach ( $files as $file ) { if ( $completed > 0 && ( $sent >= $this->bytes || microtime( true ) >= $this->deadline ) ) { break; } $rel = self::rel_from_args( $file ); $header = array( 'i' => $completed ); $path = null === $rel ? null : Roots::resolve_existing( $root, $rel ); if ( null === $path || ! is_file( $path ) ) { $header['status'] = 'missing'; Frame_Codec::add_json( $this->enc, 'F', $header ); ++$completed; continue; } clearstatcache( true, $path ); $size = (int) filesize( $path ); if ( $size > $limit ) { $header['status'] = 'too_big'; $header['s'] = $size; Frame_Codec::add_json( $this->enc, 'F', $header ); ++$completed; continue; } $data = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( false === $data ) { $header['status'] = 'unreadable'; Frame_Codec::add_json( $this->enc, 'F', $header ); ++$completed; continue; } $header['status'] = 'ok'; $header['s'] = strlen( $data ); $header['m'] = (int) filemtime( $path ); $header['h'] = hash( 'sha256', $data ); $header['enc'] = 'raw'; if ( $header['s'] > 512 && Fs::is_compressible( $rel ) ) { $packed = gzdeflate( $data, 3 ); if ( false !== $packed && strlen( $packed ) < $header['s'] ) { $data = $packed; $header['enc'] = 'deflate'; } } Frame_Codec::add_json( $this->enc, 'F', $header ); Frame_Codec::add( $this->enc, 'D', $data ); $sent += strlen( $data ); ++$completed; } return array( 'ok' => true, 'completed' => $completed, ); } /** * A byte range of one (big) file. * * @param array $args {root, p|p64, offset, length}. */ private function op_fs_read_range( array $args ) { $root = (string) ( $args['root'] ?? '' ); $rel = self::rel_from_args( $args ); $offset = max( 0, (int) ( $args['offset'] ?? 0 ) ); $length = max( 1, min( $this->bytes, (int) ( $args['length'] ?? $this->bytes ) ) ); $path = null === $rel ? null : Roots::resolve_existing( $root, $rel ); if ( null === $path || ! is_file( $path ) ) { Frame_Codec::add_json( $this->enc, 'F', array( 'status' => 'missing' ) ); return array( 'ok' => true ); } clearstatcache( true, $path ); $fh = @fopen( $path, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( ! $fh ) { Frame_Codec::add_json( $this->enc, 'F', array( 'status' => 'unreadable' ) ); return array( 'ok' => true ); } $stat = fstat( $fh ); fseek( $fh, $offset ); $data = ''; while ( strlen( $data ) < $length && ! feof( $fh ) ) { $piece = fread( $fh, min( 1048576, $length - strlen( $data ) ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( false === $piece || '' === $piece ) { break; } $data .= $piece; } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose Frame_Codec::add_json( $this->enc, 'F', array( 'status' => 'ok', 's' => (int) $stat['size'], 'm' => (int) $stat['mtime'], 'offset' => $offset, 'len' => strlen( $data ), 'h' => hash( 'sha256', $data ), ) ); Frame_Codec::add( $this->enc, 'R', $data ); return array( 'ok' => true ); } /** * Session end (audit only). * * @param array $args Summary. * @param array $key Key. */ private function op_bye( array $args, array $key ) { Logger::audit( 'Session finished.', array( 'ip' => Request_Guard::client_ip(), 'kid' => $key['kid'], 'bytes' => (int) ( $args['bytes'] ?? 0 ), 'files' => (int) ( $args['files'] ?? 0 ), ) ); Frame_Codec::add_json( $this->enc, 'H', array( 'bye' => true ) ); return array( 'ok' => true ); } /** * Relative path from {p} or {p64}. * * @param array $args Args. * @return string|null */ private static function rel_from_args( array $args ) { if ( isset( $args['p64'] ) ) { $raw = base64_decode( (string) $args['p64'], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode return false === $raw ? null : Fs::clean_relative( $raw ); } return Fs::clean_relative( (string) ( $args['p'] ?? '' ) ); } /** * Option names never exported (our secrets), plus transients unless enabled. * * @return string[] */ public static function excluded_option_patterns() { // None of Site Replica's own options (secrets, storage location, schedule): a restore keeps the // destination's own anyway. Also the old WP Replica plugin's Google app and token. $names = array_merge( Secrets::OPTIONS, array( 'accessnow_replica_%', 'wpreplica_drive_%' ) ); if ( ! Settings::get( 'include_transients' ) ) { $names[] = '_transient_%'; $names[] = '_site_transient_%'; } return $names; } /** * WHERE condition that removes excluded option rows. * * @return array{0: string, 1: array} SQL with placeholders, and their values. */ private function options_filter() { global $wpdb; $conds = array(); $args = array(); $exact = array(); foreach ( self::excluded_option_patterns() as $pattern ) { if ( '%' === substr( $pattern, -1 ) ) { $conds[] = '`option_name` NOT LIKE %s'; $args[] = $wpdb->esc_like( substr( $pattern, 0, -1 ) ) . '%'; } else { $exact[] = $pattern; } } if ( $exact ) { $conds[] = '`option_name` NOT IN (' . implode( ',', array_fill( 0, count( $exact ), '%s' ) ) . ')'; $args = array_merge( $args, $exact ); } return array( implode( ' AND ', $conds ), $args ); } /** * Session settings so exported bytes match what PHP wrote. * * @param Connection $db Connection. */ private function session( Connection $db ) { global $wpdb; try { $db->raw( "SET time_zone = '+00:00'" ); if ( $wpdb->charset ) { $wpdb->set_charset( $wpdb->dbh, $wpdb->charset, $wpdb->collate ); } } catch ( Db_Exception $e ) { unset( $e ); // Keep WordPress's defaults. } } /** * Starts or ends a consistent read snapshot for this request. * * @param Connection $db Connection. * @param bool $start Start (true) or commit (false). */ private function snapshot( Connection $db, $start ) { try { $db->raw( $start ? 'START TRANSACTION WITH CONSISTENT SNAPSHOT' : 'COMMIT' ); } catch ( Db_Exception $e ) { unset( $e ); // Not supported (e.g. MyISAM-only servers): fine. } } /** * Sends a complete response and exits. * * @param int $status HTTP status. * @param string $body Body. * @param string $type Content type. * @param array $extra Extra headers. */ public static function send( $status, $body, $type, array $extra = array() ) { while ( ob_get_level() > 0 ) { ob_end_clean(); } if ( function_exists( 'apache_setenv' ) ) { @apache_setenv( 'no-gzip', '1' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } @ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky, Squiz.PHP.DiscouragedFunctions.Discouraged -- The answer is already encrypted binary; compressing it again breaks the byte count. if ( ! headers_sent() ) { status_header( $status ); nocache_headers(); header( 'Content-Type: ' . $type ); header( 'Content-Length: ' . strlen( $body ) ); header( 'Cache-Control: no-store, no-transform, private, max-age=0' ); header( 'X-Content-Type-Options: nosniff' ); header( 'X-Robots-Tag: noindex, nofollow' ); header( 'X-Accel-Buffering: no' ); header( 'X-LiteSpeed-Cache-Control: no-cache' ); foreach ( $extra as $name => $value ) { header( $name . ': ' . $value ); } } Request_Guard::release_slot(); ignore_user_abort( false ); // If the client gave up, stop sending. echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary protocol response. exit; } }