PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.8
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.8
2.1.8 2.1.7 2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 1.5.2 1.5.3 All 108 releases
← All changes | includes/Instagram.php +162 -54 2.1.7 → 2.1.8 View file →
@@ -1,17 +1,5 @@
1 1 <?php
2 -/**
3 - * Instagram feed data for the Instagram block.
4 - *
5 - * The access token never reaches the browser: it is kept in a site option and
6 - * every Graph API call is made here, server-side. The block only ever asks this
7 - * endpoint for already-fetched media, so a published page carries no credential.
8 - *
9 - * Ported from bPlugins/my-social-feeds (includes/Instagram.php), minus the OAuth
10 - * connect screen — the token is entered in the block's own settings.
11 - *
12 - * @package bBlocks
13 - */
14 2
15 3 namespace BBlocks\Inc;
16 4
17 5 if ( ! defined( 'ABSPATH' ) ) {
@@ -21,8 +9,38 @@
21 9 class BBlocksInstagram {
22 10 const OPTION = 'b_blocks_instagram';
23 11 const CACHE_KEY = 'b_blocks_instagram_feed_';
24 12
13 + /**
14 + * Nonce action for every endpoint in this class.
15 + *
16 + * Deliberately NOT the generic 'wp_ajax' the rest of the plugin used to
17 + * share: a nonce minted for one endpoint should not authenticate a
18 + * different one. The feed is a nopriv endpoint, so this nonce is public
19 + * by construction (wp_create_nonce() for a logged-out visitor is the same
20 + * value for every anonymous visitor) - it is CSRF friction, never access
21 + * control. Authorization on the privileged endpoints comes from the
22 + * capability checks below, and abuse of the public endpoint is bounded by
23 + * the cache floor and the rate limiter in feed().
24 + */
25 + const NONCE_ACTION = 'b_blocks_instagram_feed';
26 +
27 + const CACHE_MIN_MINUTES = 5;
28 +
29 + const CACHE_MAX_MINUTES = 10080;
30 +
31 + const LOCK_SECONDS = 20;
32 +
33 + /**
34 + * Upstream Instagram fetches allowed per IP, per window, for callers who
35 + * cannot edit posts. Cache *hits* are never counted - only a cache miss
36 + * that would hit graph.instagram.com consumes budget, so ordinary visitors
37 + * loading a cached feed are unaffected no matter how many times they load
38 + * the page.
39 + */
40 + const PUBLIC_FETCH_MAX = 5;
41 + const PUBLIC_FETCH_WINDOW = 300;
42 +
25 43 public function __construct() {
26 44 add_action( 'init', [ $this, 'register_option' ] );
27 45 add_action( 'wp_ajax_bBlocksInstagramFeed', [ $this, 'feed' ] );
28 46 add_action( 'wp_ajax_nopriv_bBlocksInstagramFeed', [ $this, 'feed' ] );
@@ -32,13 +50,8 @@
32 50 add_action( 'wp_enqueue_scripts', [ $this, 'localize' ], 20 );
33 51 add_action( 'enqueue_block_editor_assets', [ $this, 'localize' ], 20 );
34 52 }
35 53
36 - /**
37 - * Registered so the option is a known site setting, but deliberately kept out
38 - * of REST: the token is only ever written through save_account() below, and is
39 - * never read back to the browser at all.
40 - */
41 54 public function register_option() {
42 55 register_setting(
43 56 'options',
44 57 self::OPTION,
@@ -57,9 +70,9 @@
57 70 $handle,
58 71 'bBlocksInstagram',
59 72 [
60 73 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
61 - 'nonce' => wp_create_nonce( 'wp_ajax' ),
74 + 'nonce' => wp_create_nonce( self::NONCE_ACTION ),
62 75 ]
63 76 );
64 77 }
65 78 }
@@ -70,9 +83,9 @@
70 83
71 84 return isset( $data['accounts'] ) && is_array( $data['accounts'] ) ? $data['accounts'] : [];
72 85 }
73 86
74 - /** The token saved on Dashboard -> Settings -> API Integrations, if any. */
87 +
75 88 public static function dashboard_token() {
76 89 $keys = get_option( 'bBlocksApiKeys', [] );
77 90
78 91 return is_array( $keys ) ? (string) ( $keys['instagram']['key'] ?? '' ) : '';
@@ -77,12 +90,9 @@
77 90
78 91 return is_array( $keys ) ? (string) ( $keys['instagram']['key'] ?? '' ) : '';
79 92 }
80 93
81 - /**
82 - * The dashboard card wins, since that is the site-wide place a token is meant
83 - * to be entered; a token stored on the block itself keeps older setups working.
84 - */
94 +
85 95 private function token_for( $account ) {
86 96 $dashboard = self::dashboard_token();
87 97
88 98 return '' !== $dashboard ? $dashboard : (string) ( $account['token'] ?? '' );
@@ -87,9 +97,9 @@
87 97
88 98 return '' !== $dashboard ? $dashboard : (string) ( $account['token'] ?? '' );
89 99 }
90 100
91 - /** Matches the account the block asked for, by username or by id. */
101 +
92 102 private function find_account( $wanted ) {
93 103 $dashboard = self::dashboard_token();
94 104
95 105 if ( '' !== $dashboard ) {
@@ -104,25 +114,20 @@
104 114
105 115 return null;
106 116 }
107 117
108 - /** Only an administrator may see or change which account is connected. */
109 118 private function guard() {
110 119 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
111 120
112 - if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) || ! current_user_can( 'manage_options' ) ) {
121 + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'manage_options' ) ) {
113 122 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
114 123 }
115 124 }
116 125
117 - /**
118 - * Reports whether a token is stored, never the token itself — it would end up
119 - * in the editor's DOM for anyone looking over the author's shoulder.
120 - */
121 126 public function get_account() {
122 127 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
123 128
124 - if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) || ! current_user_can( 'edit_posts' ) ) {
129 + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'edit_posts' ) ) {
125 130 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
126 131 }
127 132
128 133 $account = $this->accounts()[0] ?? [];
@@ -143,10 +148,8 @@
143 148 $account = $this->accounts()[0] ?? [];
144 149 $username = sanitize_text_field( wp_unslash( $_POST['username'] ?? '' ) );
145 150 $token = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
146 151
147 - // An empty token field means "leave the stored one alone", so the account
148 - // can be renamed without retyping the credential.
149 152 $saved = [
150 153 'id' => $account['id'] ?? '',
151 154 'username' => $username,
152 155 'token' => '' === $token ? ( $account['token'] ?? '' ) : $token,
@@ -158,12 +161,8 @@
158 161
159 162 wp_send_json_success( [ 'username' => $saved['username'], 'hasToken' => ! empty( $saved['token'] ) ] );
160 163 }
161 164
162 - /**
163 - * Validates a token for the dashboard's API Integrations card. Graph answers
164 - * with a 200 and an error in the body, so the body is what decides.
165 - */
166 165 public static function test_token( $token ) {
167 166 $token = trim( (string) $token );
168 167
169 168 if ( '' === $token ) {
@@ -188,9 +187,8 @@
188 187 if ( empty( $body['username'] ) ) {
189 188 return [ 'valid' => false, 'message' => __( 'Instagram did not return an account for this token', 'b-blocks' ) ];
190 189 }
191 190
192 - // A new token must not keep serving the feed the old one fetched.
193 191 self::flush();
194 192
195 193 return [ 'valid' => true, 'message' => sprintf( '%s @%s', __( 'Connected as', 'b-blocks' ), $body['username'] ) ];
196 194 }
@@ -197,15 +195,15 @@
197 195
198 196 public function feed() {
199 197 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
200 198
201 - if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) ) {
199 + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
202 200 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
203 201 }
204 202
205 203 $wanted = sanitize_text_field( wp_unslash( $_POST['account'] ?? '' ) );
206 204 $limit = min( 100, max( 1, absint( $_POST['limit'] ?? 50 ) ) );
207 - $minutes = min( 10080, max( 0, absint( $_POST['cache'] ?? 30 ) ) );
205 + $minutes = $this->cache_minutes( $_POST['cache'] ?? 30 );
208 206 $account = $this->find_account( $wanted );
209 207
210 208 $token = $account ? $this->token_for( $account ) : '';
211 209
@@ -212,17 +210,38 @@
212 210 if ( '' === $token ) {
213 211 wp_send_json_error( __( 'No Instagram account is connected. Add an access token under Dashboard → Settings → API Integrations.', 'b-blocks' ) );
214 212 }
215 213
216 - $cache_key = self::CACHE_KEY . md5( $token . '|' . $limit );
217 - $cached = $minutes ? get_transient( $cache_key ) : false;
214 + $privileged = current_user_can( 'edit_posts' );
215 + $bucket = $this->fetch_bucket( $limit, $privileged );
216 + $cache_key = self::CACHE_KEY . md5( $token . '|' . $bucket );
217 + $cached = $minutes ? get_transient( $cache_key ) : false;
218 218
219 219 if ( false !== $cached ) {
220 - wp_send_json_success( $cached );
220 + wp_send_json_success( $this->take( $cached, $limit ) );
221 221 }
222 222
223 - $payload = $this->fetch( $token, $limit );
223 + $lock = $cache_key . '_lock';
224 224
225 + if ( get_transient( $lock ) ) {
226 + wp_send_json_error( __( 'The Instagram feed is being refreshed. Please try again in a moment.', 'b-blocks' ) );
227 + }
228 +
229 + // Past this point the request costs an outbound call to Instagram.
230 + // Everything above is served from cache, so the limiter sits here
231 + // rather than at the top of the method: normal visitors on a warm
232 + // cache never touch it, and only the callers actually driving
233 + // upstream traffic spend budget.
234 + if ( ! $privileged ) {
235 + $this->throttle_fetch();
236 + }
237 +
238 + set_transient( $lock, 1, self::LOCK_SECONDS );
239 +
240 + $payload = $this->fetch( $token, $bucket );
241 +
242 + delete_transient( $lock );
243 +
225 244 if ( is_wp_error( $payload ) ) {
226 245 wp_send_json_error( $payload->get_error_message() );
227 246 }
228 247
@@ -229,16 +248,109 @@
229 248 if ( $minutes ) {
230 249 set_transient( $cache_key, $payload, $minutes * MINUTE_IN_SECONDS );
231 250 }
232 251
233 - wp_send_json_success( $payload );
252 + wp_send_json_success( $this->take( $payload, $limit ) );
234 253 }
235 254
255 + private function cache_minutes( $requested ) {
256 + $minutes = min( self::CACHE_MAX_MINUTES, absint( $requested ) );
257 +
258 + if ( current_user_can( 'edit_posts' ) ) {
259 + return $minutes;
260 + }
261 +
262 + $floor = (int) apply_filters( 'b_blocks_instagram_min_cache_minutes', self::CACHE_MIN_MINUTES );
263 +
264 + return max( $floor, $minutes );
265 + }
266 +
236 267 /**
237 - * Asks for the richer profile first. Graph rejects the whole request when a
238 - * field is not available to the token's account type, so a plain token falls
239 - * back to the fields every account has rather than returning nothing.
268 + * Per-IP throttle on cache-missing feed requests.
269 + *
270 + * Sends a 429 and exits when the caller is over budget. Mirrors the
271 + * counter in BBlocksOptinRateLimit but with its own window, because a
272 + * feed refresh and a form submission are not the same kind of traffic.
240 273 */
274 + private function throttle_fetch() {
275 + $max = (int) apply_filters( 'b_blocks_instagram_public_fetch_max', self::PUBLIC_FETCH_MAX );
276 + $window = (int) apply_filters( 'b_blocks_instagram_public_fetch_window', self::PUBLIC_FETCH_WINDOW );
277 +
278 + if ( $max <= 0 || $window <= 0 ) {
279 + return;
280 + }
281 +
282 + $key = 'bb_ig_rl_' . md5( $this->client_ip() );
283 + $hits = (int) get_transient( $key );
284 +
285 + if ( $hits >= $max ) {
286 + wp_send_json_error( __( 'Too many Instagram refreshes. Please wait a moment and try again.', 'b-blocks' ), 429 );
287 + }
288 +
289 + // The expiry is never refreshed on later hits, so the window rolls
290 + // from the first request rather than sliding forward forever.
291 + set_transient( $key, $hits + 1, $window );
292 + }
293 +
294 + /**
295 + * Client IP for the throttle key, validated so a spoofed proxy header
296 + * cannot inject arbitrary text into the transient name. A spoofed value
297 + * only changes which bucket the caller lands in; it does not skip the
298 + * check.
299 + */
300 + private function client_ip() {
301 + $candidates = [];
302 +
303 + if ( isset( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
304 + $candidates[] = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
305 + }
306 +
307 + if ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
308 + $forwarded = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
309 + $candidates[] = trim( $forwarded[0] );
310 + }
311 +
312 + if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
313 + $candidates[] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
314 + }
315 +
316 + foreach ( $candidates as $candidate ) {
317 + $ip = filter_var( trim( $candidate ), FILTER_VALIDATE_IP );
318 +
319 + if ( false !== $ip ) {
320 + return $ip;
321 + }
322 + }
323 +
324 + return '0.0.0.0';
325 + }
326 +
327 + /**
328 + * Size of the upstream request, which is also what varies the cache key.
329 + *
330 + * For callers who cannot edit posts this is pinned to the maximum, so a
331 + * public caller gets exactly one cache entry per token. Letting `limit`
332 + * pick the bucket gave them four (25/50/75/100) and therefore four ways
333 + * to force an upstream fetch inside one cache window. take() slices the
334 + * payload back down to the requested count either way, so the response
335 + * is unchanged.
336 + */
337 + private function fetch_bucket( $limit, $privileged = false ) {
338 + if ( ! $privileged ) {
339 + return 100;
340 + }
341 +
342 + return (int) min( 100, ceil( $limit / 25 ) * 25 );
343 + }
344 +
345 + private function take( $payload, $limit ) {
346 + if ( isset( $payload['media'] ) && is_array( $payload['media'] ) ) {
347 + $payload['media'] = array_slice( $payload['media'], 0, $limit );
348 + }
349 +
350 + return $payload;
351 + }
352 +
241 353 private function user( $token ) {
242 354 $sets = [
243 355 'id,username,media_count,account_type,name,profile_picture_url,followers_count',
244 356 'id,username,media_count,account_type',
@@ -285,10 +397,8 @@
285 397 }
286 398
287 399 $media = json_decode( wp_remote_retrieve_body( $media_res ), true );
288 400
289 - // Graph reports its own failures in the body with a 200, so the error has
290 - // to be read out rather than inferred from the status code.
291 401 if ( isset( $media['error']['message'] ) ) {
292 402 return new \WP_Error( 'b_blocks_instagram', $media['error']['message'] );
293 403 }
294 404
@@ -308,9 +418,9 @@
308 418
309 419 public function clear_cache() {
310 420 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
311 421
312 - if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) || ! current_user_can( 'edit_posts' ) ) {
422 + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'edit_posts' ) ) {
313 423 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
314 424 }
315 425
316 426 self::flush();
@@ -317,14 +427,12 @@
317 427
318 428 wp_send_json_success();
319 429 }
320 430
321 - /** A changed account or token must not keep serving the old feed. */
322 431 public static function flush() {
323 432 global $wpdb;
324 433
325 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- transients have no bulk delete API.
326 434 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", '_transient_' . self::CACHE_KEY . '%' ) );
327 435 }
328 436 }
329 437
330 -new BBlocksInstagram();
438 +new BBlocksInstagram();