PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.3
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.3
3.3.3 3.3.2 3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 158 releases
notificationx / includes / FrontEnd / BarSpace.php

BarSpace.php in NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar 3.3.3, at includes/FrontEnd/BarSpace.php

349 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace NotificationX\FrontEnd;
4
5 use NotificationX\Core\PostType;
6 use NotificationX\GetInstance;
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_REST_Server;
10
11 /**
12 * Reserves room for a top notification bar before it mounts.
13 *
14 * The bar is fetched over REST and rendered after page load, then pushes the
15 * page down with body padding — a layout shift on every page view. Its height
16 * is only known in the browser, so the frontend reports the rendered height per
17 * viewport width (`report_height`), and later page loads print it as body
18 * padding in <head> (`print_reserve`), so the page starts at its final position.
19 *
20 * A report can be wrong (measured before the bar's block styles or fonts
21 * arrived, zoomed text, a spoofed request), so each width bucket keeps the last
22 * few samples and reserves their median once there are enough of them.
23 *
24 * @method static BarSpace get_instance($args = null)
25 */
26 class BarSpace {
27 use GetInstance;
28
29 // v2: per-bucket samples + median (v1 stored the last report only).
30 const OPTION = 'notificationx_bar_heights_v2';
31 const MAX_HEIGHT = 400;
32 // One report per visitor IP, bar and width bucket in this window.
33 const THROTTLE = MINUTE_IN_SECONDS;
34 const SAMPLES = 5;
35 const MIN_SAMPLES = 3;
36
37 /**
38 * Viewport buckets as [ key => max width (exclusive) ]. Text wrapping
39 * changes the bar's height with width, so phones are split in two and
40 * wide desktops get their own bucket. `device` maps to the bar's
41 * mobile/tablet/desktop visibility (see getDeviceType in useNotificationX).
42 */
43 const BUCKETS = [
44 'xs' => [ 'max' => 480, 'device' => 'mobile' ],
45 'sm' => [ 'max' => 768, 'device' => 'mobile' ],
46 'md' => [ 'max' => 1024, 'device' => 'tablet' ],
47 'lg' => [ 'max' => 1440, 'device' => 'desktop' ],
48 'xl' => [ 'max' => 0, 'device' => 'desktop' ],
49 ];
50
51 // A report more than this many times the current reservation (plus a small
52 // allowance) is ignored: it can only open a gap that is not there.
53 const OUTLIER_RATIO = 2;
54 const OUTLIER_SLACK = 16;
55
56 public function __construct() {
57 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
58 // Any save can change the bar's height (text, font size, layout), and a
59 // builder save resubmits the loaded `updated_at`, so the version check
60 // alone does not notice an edit.
61 add_action( 'nx_saved_post', [ $this, 'forget_heights' ], 10, 3 );
62 }
63
64 /**
65 * Drop the stored heights of a notification that was just saved, so its old
66 * height is not reserved while visitors report the new one.
67 *
68 * @param array $post Saved post row.
69 * @param array $data Submitted data.
70 * @param int $nx_id Notification ID.
71 */
72 public function forget_heights( $post, $data, $nx_id ) {
73 $nx_id = absint( $nx_id );
74 $heights = get_option( self::OPTION, [] );
75 if ( $nx_id && is_array( $heights ) && isset( $heights[ $nx_id ] ) ) {
76 unset( $heights[ $nx_id ] );
77 update_option( self::OPTION, $heights, true );
78 }
79 }
80
81 /**
82 * The address a height report came from, used to throttle it and to count
83 * each visitor once.
84 *
85 * `REMOTE_ADDR` is the only value a client cannot set. When it is a private
86 * or loopback address the request reached PHP through a reverse proxy on the
87 * site's own network, and every visitor would share it — the median would
88 * never get enough samples. In that case the address the proxy appended to
89 * `X-Forwarded-For` (its last entry), or `X-Real-IP`, identifies the
90 * visitor. A public `REMOTE_ADDR` is used as is: forwarded headers from the
91 * open internet are not trusted.
92 *
93 * @return string
94 */
95 public static function client_ip() {
96 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- isset-checked, validated with filter_var below.
97 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
98 if ( $ip && ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
99 $forwarded = '';
100 if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
101 $hops = array_map( 'trim', explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ) );
102 $forwarded = (string) end( $hops );
103 } elseif ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
104 $forwarded = trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ) );
105 }
106 if ( filter_var( $forwarded, FILTER_VALIDATE_IP ) ) {
107 $ip = $forwarded;
108 }
109 }
110 // phpcs:enable
111
112 /**
113 * Filters the visitor address used to throttle and count bar-height reports.
114 *
115 * Sites behind a CDN whose edge addresses are public (so the default above
116 * keeps them) can return the visitor's address from the CDN's header here.
117 *
118 * @since 3.3.3
119 *
120 * @param string $ip Detected address.
121 */
122 return (string) apply_filters( 'nx_bar_height_client_ip', $ip ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- nx_ is this plugin's hook prefix.
123 }
124
125 public function register_routes() {
126 register_rest_route(
127 'notificationx/v1',
128 '/bar-height',
129 [
130 'methods' => WP_REST_Server::CREATABLE,
131 'callback' => [ $this, 'report_height' ],
132 // Public, like analytics: reported by visitors' browsers.
133 'permission_callback' => '__return_true',
134 'args' => [
135 'nx_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1 ],
136 'width' => [ 'required' => true, 'type' => 'integer', 'minimum' => 200, 'maximum' => 10000 ],
137 'height' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_HEIGHT ],
138 ],
139 ]
140 );
141 }
142
143 /**
144 * Whether a bar pushes the page down from the top as soon as it loads —
145 * the only case where reserving space removes a shift instead of adding one.
146 *
147 * @param array $settings Notification settings.
148 * @return bool
149 */
150 public static function reserves_space( $settings ) {
151 if ( empty( $settings['enabled'] ) || ( $settings['source'] ?? '' ) !== 'press_bar' ) {
152 return false;
153 }
154 // `bottom_left` is rendered as a top bar (FrontEnd::get_notifications_data).
155 if ( ! in_array( $settings['position'] ?? 'top', [ 'top', 'bottom_left' ], true ) ) {
156 return false;
157 }
158 if ( ! empty( $settings['pressbar_body'] ) || ( $settings['appear_condition'] ?? '' ) === 'on_scroll' ) {
159 return false;
160 }
161 return empty( $settings['initial_delay'] ) || (float) $settings['initial_delay'] <= 0;
162 }
163
164 /**
165 * @param int $width Viewport width.
166 * @return string Bucket key.
167 */
168 public static function bucket( $width ) {
169 foreach ( self::BUCKETS as $key => $bucket ) {
170 if ( ! $bucket['max'] || $width < $bucket['max'] ) {
171 return $key;
172 }
173 }
174 return 'xl';
175 }
176
177 /**
178 * Transient name that throttles one visitor's reports for one bar and width
179 * bucket. Salted, so the stored name does not reveal the address.
180 *
181 * @param string $ip Visitor address.
182 * @param int $nx_id Notification ID.
183 * @param string $bucket Width bucket.
184 * @return string
185 */
186 public static function throttle_key( $ip, $nx_id, $bucket ) {
187 return 'nx_bar_height_' . md5( wp_hash( "{$ip}|{$nx_id}|{$bucket}" ) );
188 }
189
190 /**
191 * Lower median, so an even window leans towards the smaller height.
192 *
193 * @param int[] $values
194 * @return int
195 */
196 public static function median( array $values ) {
197 sort( $values );
198 return (int) $values[ ( count( $values ) - 1 ) >> 1 ];
199 }
200
201 public function report_height( WP_REST_Request $request ) {
202 $nx_id = absint( $request['nx_id'] );
203 $bucket = self::bucket( (int) $request['width'] );
204 $height = (int) $request['height'];
205
206 $posts = PostType::get_instance()->get_posts_by_ids( [ $nx_id ], 'press_bar' );
207 $settings = $posts ? reset( $posts ) : null;
208 if ( ! $settings || ! self::reserves_space( $settings ) ) {
209 return new WP_REST_Response( [ 'saved' => false ], 200 );
210 }
211
212 // The endpoint is public, so a single client must not be able to set the
213 // gap every visitor sees. Throttle per IP (not per bar, which would also
214 // block real browsers from correcting a bad value); the median below
215 // takes at most one sample per IP.
216 $ip = self::client_ip();
217 $throttle_key = self::throttle_key( $ip, $nx_id, $bucket );
218 if ( get_transient( $throttle_key ) ) {
219 return new WP_REST_Response( [ 'saved' => false ], 200 );
220 }
221 set_transient( $throttle_key, 1, self::THROTTLE );
222
223 $version = (string) ( $settings['updated_at'] ?? '' );
224 $heights = get_option( self::OPTION, [] );
225 if ( ! is_array( $heights ) ) {
226 $heights = [];
227 }
228 $entry = isset( $heights[ $nx_id ] ) && is_array( $heights[ $nx_id ] ) && ( $heights[ $nx_id ]['v'] ?? '' ) === $version
229 ? $heights[ $nx_id ]
230 : [ 'v' => $version, 's' => [], 'h' => [] ];
231
232 $samples = isset( $entry['s'][ $bucket ] ) && is_array( $entry['s'][ $bucket ] ) ? array_values( $entry['s'][ $bucket ] ) : [];
233 $sources = isset( $entry['i'][ $bucket ] ) && is_array( $entry['i'][ $bucket ] ) ? array_values( $entry['i'][ $bucket ] ) : [];
234 if ( count( $sources ) !== count( $samples ) ) {
235 $sources = array_fill( 0, count( $samples ), '' );
236 }
237 // Settled: a full window already agrees with this report.
238 if ( count( $samples ) >= self::SAMPLES && isset( $entry['h'][ $bucket ] ) && abs( $entry['h'][ $bucket ] - $height ) < 2 ) {
239 return new WP_REST_Response( [ 'saved' => false ], 200 );
240 }
241 // Once a height is reserved, a report far above it is not the bar (an
242 // edit clears the window, see forget_heights()) — it would only open a
243 // gap above the page, so it never gets a vote.
244 if ( isset( $entry['h'][ $bucket ] ) && $height > $entry['h'][ $bucket ] * self::OUTLIER_RATIO + self::OUTLIER_SLACK ) {
245 return new WP_REST_Response( [ 'saved' => false ], 200 );
246 }
247
248 // One sample per source IP in the window, so a single client cannot
249 // supply the majority of samples and pick the median. Stored as a salted
250 // hash, never the address.
251 $source = substr( wp_hash( $ip ), 0, 8 );
252 $index = array_search( $source, $sources, true );
253 if ( false !== $index ) {
254 array_splice( $samples, $index, 1 );
255 array_splice( $sources, $index, 1 );
256 }
257 $samples[] = $height;
258 $sources[] = $source;
259 $samples = array_slice( $samples, -self::SAMPLES );
260 $sources = array_slice( $sources, -self::SAMPLES );
261
262 $entry['s'][ $bucket ] = $samples;
263 $entry['i'][ $bucket ] = $sources;
264 $saved = false;
265 if ( count( $samples ) >= self::MIN_SAMPLES ) {
266 $entry['h'][ $bucket ] = self::median( $samples );
267 $saved = true;
268 }
269 $heights[ $nx_id ] = $entry;
270 // Autoloaded: print_reserve() reads it on every front-end page with a bar.
271 update_option( self::OPTION, $heights, true );
272 // v1 storage (single value per bucket) is no longer read.
273 delete_option( 'notificationx_bar_heights' );
274
275 return new WP_REST_Response( [ 'saved' => $saved ], 200 );
276 }
277
278 /**
279 * Print the reservation for the page's first eligible top bar.
280 *
281 * Plain CSS with media queries, so it applies before first paint even when
282 * an optimizer delays inline scripts. The frontend adds
283 * `nx-bar-reserve-off` to <html> once the bar has set its own padding, or
284 * when no bar is shown (closed, hidden on this device, schedule).
285 *
286 * @param int[] $bar_ids Press bar IDs active on this page.
287 */
288 public function print_reserve( $bar_ids ) {
289 if ( empty( $bar_ids ) ) {
290 return;
291 }
292 $heights = get_option( self::OPTION, [] );
293 if ( empty( $heights ) || ! is_array( $heights ) ) {
294 return;
295 }
296
297 foreach ( PostType::get_instance()->get_posts_by_ids( $bar_ids, 'press_bar' ) as $settings ) {
298 $nx_id = absint( $settings['nx_id'] );
299 $entry = $heights[ $nx_id ] ?? null;
300 if ( ! $entry || ( $entry['v'] ?? '' ) !== (string) ( $settings['updated_at'] ?? '' ) || empty( $entry['h'] ) || ! self::reserves_space( $settings ) ) {
301 continue;
302 }
303
304 $rules = [];
305 $min = 0;
306 foreach ( self::BUCKETS as $key => $bucket ) {
307 $height = isset( $entry['h'][ $key ] ) ? min( self::MAX_HEIGHT, absint( $entry['h'][ $key ] ) ) : 0;
308 // Respect per-device visibility (`hide_on_*` set means "show").
309 $visible = ! empty( $settings[ 'mobile' === $bucket['device'] ? 'hide_on_mobile' : ( 'tablet' === $bucket['device'] ? 'hide_on_tab' : 'hide_on_desktop' ) ] );
310 if ( $height && $visible ) {
311 // Only while scripts run: without JavaScript the bar never
312 // mounts and nothing would release the space. Browsers that
313 // do not know `scripting` skip the rule (no reservation).
314 $query = [ '(scripting:enabled)' ];
315 if ( $min ) {
316 $query[] = "(min-width:{$min}px)";
317 }
318 if ( $bucket['max'] ) {
319 $query[] = '(max-width:' . ( $bucket['max'] - 0.02 ) . 'px)';
320 }
321 $rule = "html:not(.nx-bar-reserve-off) body{padding-top:{$height}px}";
322 $rules[] = '@media ' . implode( ' and ', $query ) . "{{$rule}}";
323 }
324 $min = $bucket['max'];
325 }
326
327 if ( $rules ) {
328 printf(
329 "<style id=\"nx-bar-reserve\" data-nx-id=\"%d\" data-heights=\"%s\">%s</style>\n",
330 esc_attr( $nx_id ),
331 esc_attr( wp_json_encode( $entry['h'] ) ),
332 implode( '', $rules ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from integers above.
333 );
334 // A page cache serves this reservation to visitors who closed the
335 // bar too; for them the bar never mounts and the page would jump
336 // up once the frontend releases it. Check the close cookie before
337 // first paint. Tagged so optimizers do not delay it.
338 $cookie = 'notificationx_' . $nx_id . ( ! empty( $settings['countdown_rand'] ) ? '-' . $settings['countdown_rand'] : '' );
339 printf(
340 "<script data-no-optimize=\"1\" data-cfasync=\"false\" data-no-defer=\"1\" nowprocket>(function(n){try{if(document.cookie.split('; ').some(function(c){var i=c.indexOf('=');return c.slice(0,i)===n&&c.slice(i+1)!==''&&c.slice(i+1)!=='false';}))document.documentElement.classList.add('nx-bar-reserve-off');}catch(e){}})(%s);</script>\n",
341 wp_json_encode( $cookie ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-encoded string literal.
342 );
343 }
344 // Only one top bar sets the body padding.
345 return;
346 }
347 }
348 }
349