(`print_reserve`), so the page starts at its final position. * * A report can be wrong (measured before the bar's block styles or fonts * arrived, zoomed text, a spoofed request), so each width bucket keeps the last * few samples and reserves their median once there are enough of them. * * @method static BarSpace get_instance($args = null) */ class BarSpace { use GetInstance; // v2: per-bucket samples + median (v1 stored the last report only). const OPTION = 'notificationx_bar_heights_v2'; const MAX_HEIGHT = 400; // One report per visitor IP, bar and width bucket in this window. const THROTTLE = MINUTE_IN_SECONDS; const SAMPLES = 5; const MIN_SAMPLES = 3; /** * Viewport buckets as [ key => max width (exclusive) ]. Text wrapping * changes the bar's height with width, so phones are split in two and * wide desktops get their own bucket. `device` maps to the bar's * mobile/tablet/desktop visibility (see getDeviceType in useNotificationX). */ const BUCKETS = [ 'xs' => [ 'max' => 480, 'device' => 'mobile' ], 'sm' => [ 'max' => 768, 'device' => 'mobile' ], 'md' => [ 'max' => 1024, 'device' => 'tablet' ], 'lg' => [ 'max' => 1440, 'device' => 'desktop' ], 'xl' => [ 'max' => 0, 'device' => 'desktop' ], ]; // A report more than this many times the current reservation (plus a small // allowance) is ignored: it can only open a gap that is not there. const OUTLIER_RATIO = 2; const OUTLIER_SLACK = 16; public function __construct() { add_action( 'rest_api_init', [ $this, 'register_routes' ] ); // Any save can change the bar's height (text, font size, layout), and a // builder save resubmits the loaded `updated_at`, so the version check // alone does not notice an edit. add_action( 'nx_saved_post', [ $this, 'forget_heights' ], 10, 3 ); } /** * Drop the stored heights of a notification that was just saved, so its old * height is not reserved while visitors report the new one. * * @param array $post Saved post row. * @param array $data Submitted data. * @param int $nx_id Notification ID. */ public function forget_heights( $post, $data, $nx_id ) { $nx_id = absint( $nx_id ); $heights = get_option( self::OPTION, [] ); if ( $nx_id && is_array( $heights ) && isset( $heights[ $nx_id ] ) ) { unset( $heights[ $nx_id ] ); update_option( self::OPTION, $heights, true ); } } /** * The address a height report came from, used to throttle it and to count * each visitor once. * * `REMOTE_ADDR` is the only value a client cannot set. When it is a private * or loopback address the request reached PHP through a reverse proxy on the * site's own network, and every visitor would share it — the median would * never get enough samples. In that case the address the proxy appended to * `X-Forwarded-For` (its last entry), or `X-Real-IP`, identifies the * visitor. A public `REMOTE_ADDR` is used as is: forwarded headers from the * open internet are not trusted. * * @return string */ public static function client_ip() { // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- isset-checked, validated with filter_var below. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( $ip && ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { $forwarded = ''; if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $hops = array_map( 'trim', explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ) ); $forwarded = (string) end( $hops ); } elseif ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) { $forwarded = trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ) ); } if ( filter_var( $forwarded, FILTER_VALIDATE_IP ) ) { $ip = $forwarded; } } // phpcs:enable /** * Filters the visitor address used to throttle and count bar-height reports. * * Sites behind a CDN whose edge addresses are public (so the default above * keeps them) can return the visitor's address from the CDN's header here. * * @since 3.3.3 * * @param string $ip Detected address. */ return (string) apply_filters( 'nx_bar_height_client_ip', $ip ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- nx_ is this plugin's hook prefix. } public function register_routes() { register_rest_route( 'notificationx/v1', '/bar-height', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'report_height' ], // Public, like analytics: reported by visitors' browsers. 'permission_callback' => '__return_true', 'args' => [ 'nx_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1 ], 'width' => [ 'required' => true, 'type' => 'integer', 'minimum' => 200, 'maximum' => 10000 ], 'height' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_HEIGHT ], ], ] ); } /** * Whether a bar pushes the page down from the top as soon as it loads — * the only case where reserving space removes a shift instead of adding one. * * @param array $settings Notification settings. * @return bool */ public static function reserves_space( $settings ) { if ( empty( $settings['enabled'] ) || ( $settings['source'] ?? '' ) !== 'press_bar' ) { return false; } // `bottom_left` is rendered as a top bar (FrontEnd::get_notifications_data). if ( ! in_array( $settings['position'] ?? 'top', [ 'top', 'bottom_left' ], true ) ) { return false; } if ( ! empty( $settings['pressbar_body'] ) || ( $settings['appear_condition'] ?? '' ) === 'on_scroll' ) { return false; } return empty( $settings['initial_delay'] ) || (float) $settings['initial_delay'] <= 0; } /** * @param int $width Viewport width. * @return string Bucket key. */ public static function bucket( $width ) { foreach ( self::BUCKETS as $key => $bucket ) { if ( ! $bucket['max'] || $width < $bucket['max'] ) { return $key; } } return 'xl'; } /** * Transient name that throttles one visitor's reports for one bar and width * bucket. Salted, so the stored name does not reveal the address. * * @param string $ip Visitor address. * @param int $nx_id Notification ID. * @param string $bucket Width bucket. * @return string */ public static function throttle_key( $ip, $nx_id, $bucket ) { return 'nx_bar_height_' . md5( wp_hash( "{$ip}|{$nx_id}|{$bucket}" ) ); } /** * Lower median, so an even window leans towards the smaller height. * * @param int[] $values * @return int */ public static function median( array $values ) { sort( $values ); return (int) $values[ ( count( $values ) - 1 ) >> 1 ]; } public function report_height( WP_REST_Request $request ) { $nx_id = absint( $request['nx_id'] ); $bucket = self::bucket( (int) $request['width'] ); $height = (int) $request['height']; $posts = PostType::get_instance()->get_posts_by_ids( [ $nx_id ], 'press_bar' ); $settings = $posts ? reset( $posts ) : null; if ( ! $settings || ! self::reserves_space( $settings ) ) { return new WP_REST_Response( [ 'saved' => false ], 200 ); } // The endpoint is public, so a single client must not be able to set the // gap every visitor sees. Throttle per IP (not per bar, which would also // block real browsers from correcting a bad value); the median below // takes at most one sample per IP. $ip = self::client_ip(); $throttle_key = self::throttle_key( $ip, $nx_id, $bucket ); if ( get_transient( $throttle_key ) ) { return new WP_REST_Response( [ 'saved' => false ], 200 ); } set_transient( $throttle_key, 1, self::THROTTLE ); $version = (string) ( $settings['updated_at'] ?? '' ); $heights = get_option( self::OPTION, [] ); if ( ! is_array( $heights ) ) { $heights = []; } $entry = isset( $heights[ $nx_id ] ) && is_array( $heights[ $nx_id ] ) && ( $heights[ $nx_id ]['v'] ?? '' ) === $version ? $heights[ $nx_id ] : [ 'v' => $version, 's' => [], 'h' => [] ]; $samples = isset( $entry['s'][ $bucket ] ) && is_array( $entry['s'][ $bucket ] ) ? array_values( $entry['s'][ $bucket ] ) : []; $sources = isset( $entry['i'][ $bucket ] ) && is_array( $entry['i'][ $bucket ] ) ? array_values( $entry['i'][ $bucket ] ) : []; if ( count( $sources ) !== count( $samples ) ) { $sources = array_fill( 0, count( $samples ), '' ); } // Settled: a full window already agrees with this report. if ( count( $samples ) >= self::SAMPLES && isset( $entry['h'][ $bucket ] ) && abs( $entry['h'][ $bucket ] - $height ) < 2 ) { return new WP_REST_Response( [ 'saved' => false ], 200 ); } // Once a height is reserved, a report far above it is not the bar (an // edit clears the window, see forget_heights()) — it would only open a // gap above the page, so it never gets a vote. if ( isset( $entry['h'][ $bucket ] ) && $height > $entry['h'][ $bucket ] * self::OUTLIER_RATIO + self::OUTLIER_SLACK ) { return new WP_REST_Response( [ 'saved' => false ], 200 ); } // One sample per source IP in the window, so a single client cannot // supply the majority of samples and pick the median. Stored as a salted // hash, never the address. $source = substr( wp_hash( $ip ), 0, 8 ); $index = array_search( $source, $sources, true ); if ( false !== $index ) { array_splice( $samples, $index, 1 ); array_splice( $sources, $index, 1 ); } $samples[] = $height; $sources[] = $source; $samples = array_slice( $samples, -self::SAMPLES ); $sources = array_slice( $sources, -self::SAMPLES ); $entry['s'][ $bucket ] = $samples; $entry['i'][ $bucket ] = $sources; $saved = false; if ( count( $samples ) >= self::MIN_SAMPLES ) { $entry['h'][ $bucket ] = self::median( $samples ); $saved = true; } $heights[ $nx_id ] = $entry; // Autoloaded: print_reserve() reads it on every front-end page with a bar. update_option( self::OPTION, $heights, true ); // v1 storage (single value per bucket) is no longer read. delete_option( 'notificationx_bar_heights' ); return new WP_REST_Response( [ 'saved' => $saved ], 200 ); } /** * Print the reservation for the page's first eligible top bar. * * Plain CSS with media queries, so it applies before first paint even when * an optimizer delays inline scripts. The frontend adds * `nx-bar-reserve-off` to once the bar has set its own padding, or * when no bar is shown (closed, hidden on this device, schedule). * * @param int[] $bar_ids Press bar IDs active on this page. */ public function print_reserve( $bar_ids ) { if ( empty( $bar_ids ) ) { return; } $heights = get_option( self::OPTION, [] ); if ( empty( $heights ) || ! is_array( $heights ) ) { return; } foreach ( PostType::get_instance()->get_posts_by_ids( $bar_ids, 'press_bar' ) as $settings ) { $nx_id = absint( $settings['nx_id'] ); $entry = $heights[ $nx_id ] ?? null; if ( ! $entry || ( $entry['v'] ?? '' ) !== (string) ( $settings['updated_at'] ?? '' ) || empty( $entry['h'] ) || ! self::reserves_space( $settings ) ) { continue; } $rules = []; $min = 0; foreach ( self::BUCKETS as $key => $bucket ) { $height = isset( $entry['h'][ $key ] ) ? min( self::MAX_HEIGHT, absint( $entry['h'][ $key ] ) ) : 0; // Respect per-device visibility (`hide_on_*` set means "show"). $visible = ! empty( $settings[ 'mobile' === $bucket['device'] ? 'hide_on_mobile' : ( 'tablet' === $bucket['device'] ? 'hide_on_tab' : 'hide_on_desktop' ) ] ); if ( $height && $visible ) { // Only while scripts run: without JavaScript the bar never // mounts and nothing would release the space. Browsers that // do not know `scripting` skip the rule (no reservation). $query = [ '(scripting:enabled)' ]; if ( $min ) { $query[] = "(min-width:{$min}px)"; } if ( $bucket['max'] ) { $query[] = '(max-width:' . ( $bucket['max'] - 0.02 ) . 'px)'; } $rule = "html:not(.nx-bar-reserve-off) body{padding-top:{$height}px}"; $rules[] = '@media ' . implode( ' and ', $query ) . "{{$rule}}"; } $min = $bucket['max']; } if ( $rules ) { printf( "\n", esc_attr( $nx_id ), esc_attr( wp_json_encode( $entry['h'] ) ), implode( '', $rules ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from integers above. ); // A page cache serves this reservation to visitors who closed the // bar too; for them the bar never mounts and the page would jump // up once the frontend releases it. Check the close cookie before // first paint. Tagged so optimizers do not delay it. $cookie = 'notificationx_' . $nx_id . ( ! empty( $settings['countdown_rand'] ) ? '-' . $settings['countdown_rand'] : '' ); printf( "\n", wp_json_encode( $cookie ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-encoded string literal. ); } // Only one top bar sets the body padding. return; } } }