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 +180 -63 2.1.6 → 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,20 +90,23 @@
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 - return '' === $dashboard ? (string) ( $account['token'] ?? '' ) : $dashboard;
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 ) {
103 + $dashboard = self::dashboard_token();
104 +
105 + if ( '' !== $dashboard ) {
106 + return [ 'id' => '', 'username' => $wanted, 'token' => $dashboard ];
107 + }
108 +
93 109 foreach ( $this->accounts() as $account ) {
94 110 if ( '' === $wanted || ( $account['username'] ?? '' ) === $wanted || (string) ( $account['id'] ?? '' ) === (string) $wanted ) {
95 111 return $account;
96 112 }
@@ -95,36 +111,34 @@
95 111 return $account;
96 112 }
97 113 }
98 114
99 - // With a token in the dashboard, the feed works before any account has been
100 - // saved on the block itself — the token already identifies the account.
101 - return '' === self::dashboard_token() ? null : [ 'id' => '', 'username' => $wanted, 'token' => '' ];
115 + return null;
102 116 }
103 117
104 - /** Only an administrator may see or change which account is connected. */
105 118 private function guard() {
106 119 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
107 120
108 - 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' ) ) {
109 122 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
110 123 }
111 124 }
112 125
113 - /**
114 - * Reports whether a token is stored, never the token itself — it would end up
115 - * in the editor's DOM for anyone looking over the author's shoulder.
116 - */
117 126 public function get_account() {
118 - $this->guard();
127 + $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
119 128
120 - $account = $this->accounts()[0] ?? [];
129 + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'edit_posts' ) ) {
130 + wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
131 + }
121 132
133 + $account = $this->accounts()[0] ?? [];
134 + $dashboard = self::dashboard_token();
135 +
122 136 wp_send_json_success(
123 137 [
124 - 'username' => $account['username'] ?? '',
125 - 'hasToken' => '' !== $this->token_for( $account ),
126 - 'fromDashboard' => '' !== self::dashboard_token(),
138 + 'username' => $account['username'] ?? '',
139 + 'hasToken' => '' !== $dashboard || '' !== ( $account['token'] ?? '' ),
140 + 'fromDashboard' => '' !== $dashboard,
127 141 ]
128 142 );
129 143 }
130 144
@@ -134,10 +148,8 @@
134 148 $account = $this->accounts()[0] ?? [];
135 149 $username = sanitize_text_field( wp_unslash( $_POST['username'] ?? '' ) );
136 150 $token = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
137 151
138 - // An empty token field means "leave the stored one alone", so the account
139 - // can be renamed without retyping the credential.
140 152 $saved = [
141 153 'id' => $account['id'] ?? '',
142 154 'username' => $username,
143 155 'token' => '' === $token ? ( $account['token'] ?? '' ) : $token,
@@ -149,12 +161,8 @@
149 161
150 162 wp_send_json_success( [ 'username' => $saved['username'], 'hasToken' => ! empty( $saved['token'] ) ] );
151 163 }
152 164
153 - /**
154 - * Validates a token for the dashboard's API Integrations card. Graph answers
155 - * with a 200 and an error in the body, so the body is what decides.
156 - */
157 165 public static function test_token( $token ) {
158 166 $token = trim( (string) $token );
159 167
160 168 if ( '' === $token ) {
@@ -179,9 +187,8 @@
179 187 if ( empty( $body['username'] ) ) {
180 188 return [ 'valid' => false, 'message' => __( 'Instagram did not return an account for this token', 'b-blocks' ) ];
181 189 }
182 190
183 - // A new token must not keep serving the feed the old one fetched.
184 191 self::flush();
185 192
186 193 return [ 'valid' => true, 'message' => sprintf( '%s @%s', __( 'Connected as', 'b-blocks' ), $body['username'] ) ];
187 194 }
@@ -188,15 +195,15 @@
188 195
189 196 public function feed() {
190 197 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
191 198
192 - if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) ) {
199 + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
193 200 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
194 201 }
195 202
196 203 $wanted = sanitize_text_field( wp_unslash( $_POST['account'] ?? '' ) );
197 204 $limit = min( 100, max( 1, absint( $_POST['limit'] ?? 50 ) ) );
198 - $minutes = min( 10080, max( 0, absint( $_POST['cache'] ?? 30 ) ) );
205 + $minutes = $this->cache_minutes( $_POST['cache'] ?? 30 );
199 206 $account = $this->find_account( $wanted );
200 207
201 208 $token = $account ? $this->token_for( $account ) : '';
202 209
@@ -203,17 +210,38 @@
203 210 if ( '' === $token ) {
204 211 wp_send_json_error( __( 'No Instagram account is connected. Add an access token under Dashboard → Settings → API Integrations.', 'b-blocks' ) );
205 212 }
206 213
207 - $cache_key = self::CACHE_KEY . md5( $token . '|' . $limit );
208 - $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;
209 218
210 219 if ( false !== $cached ) {
211 - wp_send_json_success( $cached );
220 + wp_send_json_success( $this->take( $cached, $limit ) );
212 221 }
213 222
214 - $payload = $this->fetch( $token, $limit );
223 + $lock = $cache_key . '_lock';
215 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 +
216 244 if ( is_wp_error( $payload ) ) {
217 245 wp_send_json_error( $payload->get_error_message() );
218 246 }
219 247
@@ -220,16 +248,109 @@
220 248 if ( $minutes ) {
221 249 set_transient( $cache_key, $payload, $minutes * MINUTE_IN_SECONDS );
222 250 }
223 251
224 - wp_send_json_success( $payload );
252 + wp_send_json_success( $this->take( $payload, $limit ) );
225 253 }
226 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 +
227 267 /**
228 - * Asks for the richer profile first. Graph rejects the whole request when a
229 - * field is not available to the token's account type, so a plain token falls
230 - * 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.
231 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 +
232 353 private function user( $token ) {
233 354 $sets = [
234 355 'id,username,media_count,account_type,name,profile_picture_url,followers_count',
235 356 'id,username,media_count,account_type',
@@ -276,10 +397,8 @@
276 397 }
277 398
278 399 $media = json_decode( wp_remote_retrieve_body( $media_res ), true );
279 400
280 - // Graph reports its own failures in the body with a 200, so the error has
281 - // to be read out rather than inferred from the status code.
282 401 if ( isset( $media['error']['message'] ) ) {
283 402 return new \WP_Error( 'b_blocks_instagram', $media['error']['message'] );
284 403 }
285 404
@@ -299,9 +418,9 @@
299 418
300 419 public function clear_cache() {
301 420 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
302 421
303 - 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' ) ) {
304 423 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
305 424 }
306 425
307 426 self::flush();
@@ -308,14 +427,12 @@
308 427
309 428 wp_send_json_success();
310 429 }
311 430
312 - /** A changed account or token must not keep serving the old feed. */
313 - private static function flush() {
431 + public static function flush() {
314 432 global $wpdb;
315 433
316 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- transients have no bulk delete API.
317 434 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", '_transient_' . self::CACHE_KEY . '%' ) );
318 435 }
319 436 }
320 437
321 -new BBlocksInstagram();
438 +new BBlocksInstagram();