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
b-blocks / includes / Instagram.php

Instagram.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection 2.1.8, at includes/Instagram.php

438 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BBlocks\Inc;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 class BBlocksInstagram {
10 const OPTION = 'b_blocks_instagram';
11 const CACHE_KEY = 'b_blocks_instagram_feed_';
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
43 public function __construct() {
44 add_action( 'init', [ $this, 'register_option' ] );
45 add_action( 'wp_ajax_bBlocksInstagramFeed', [ $this, 'feed' ] );
46 add_action( 'wp_ajax_nopriv_bBlocksInstagramFeed', [ $this, 'feed' ] );
47 add_action( 'wp_ajax_bBlocksInstagramClearCache', [ $this, 'clear_cache' ] );
48 add_action( 'wp_ajax_bBlocksInstagramGetAccount', [ $this, 'get_account' ] );
49 add_action( 'wp_ajax_bBlocksInstagramSaveAccount', [ $this, 'save_account' ] );
50 add_action( 'wp_enqueue_scripts', [ $this, 'localize' ], 20 );
51 add_action( 'enqueue_block_editor_assets', [ $this, 'localize' ], 20 );
52 }
53
54 public function register_option() {
55 register_setting(
56 'options',
57 self::OPTION,
58 [
59 'type' => 'object',
60 'default' => [ 'accounts' => [] ],
61 'show_in_rest' => false,
62 ]
63 );
64 }
65
66 public function localize() {
67 foreach ( [ 'b-blocks-instagram-view-script', 'b-blocks-index-script' ] as $handle ) {
68 if ( wp_script_is( $handle, 'registered' ) ) {
69 wp_localize_script(
70 $handle,
71 'bBlocksInstagram',
72 [
73 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
74 'nonce' => wp_create_nonce( self::NONCE_ACTION ),
75 ]
76 );
77 }
78 }
79 }
80
81 private function accounts() {
82 $data = get_option( self::OPTION, [] );
83
84 return isset( $data['accounts'] ) && is_array( $data['accounts'] ) ? $data['accounts'] : [];
85 }
86
87
88 public static function dashboard_token() {
89 $keys = get_option( 'bBlocksApiKeys', [] );
90
91 return is_array( $keys ) ? (string) ( $keys['instagram']['key'] ?? '' ) : '';
92 }
93
94
95 private function token_for( $account ) {
96 $dashboard = self::dashboard_token();
97
98 return '' !== $dashboard ? $dashboard : (string) ( $account['token'] ?? '' );
99 }
100
101
102 private function find_account( $wanted ) {
103 $dashboard = self::dashboard_token();
104
105 if ( '' !== $dashboard ) {
106 return [ 'id' => '', 'username' => $wanted, 'token' => $dashboard ];
107 }
108
109 foreach ( $this->accounts() as $account ) {
110 if ( '' === $wanted || ( $account['username'] ?? '' ) === $wanted || (string) ( $account['id'] ?? '' ) === (string) $wanted ) {
111 return $account;
112 }
113 }
114
115 return null;
116 }
117
118 private function guard() {
119 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
120
121 if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'manage_options' ) ) {
122 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
123 }
124 }
125
126 public function get_account() {
127 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
128
129 if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'edit_posts' ) ) {
130 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
131 }
132
133 $account = $this->accounts()[0] ?? [];
134 $dashboard = self::dashboard_token();
135
136 wp_send_json_success(
137 [
138 'username' => $account['username'] ?? '',
139 'hasToken' => '' !== $dashboard || '' !== ( $account['token'] ?? '' ),
140 'fromDashboard' => '' !== $dashboard,
141 ]
142 );
143 }
144
145 public function save_account() {
146 $this->guard();
147
148 $account = $this->accounts()[0] ?? [];
149 $username = sanitize_text_field( wp_unslash( $_POST['username'] ?? '' ) );
150 $token = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
151
152 $saved = [
153 'id' => $account['id'] ?? '',
154 'username' => $username,
155 'token' => '' === $token ? ( $account['token'] ?? '' ) : $token,
156 ];
157
158 update_option( self::OPTION, [ 'accounts' => '' === $saved['username'] && '' === $saved['token'] ? [] : [ $saved ] ] );
159
160 self::flush();
161
162 wp_send_json_success( [ 'username' => $saved['username'], 'hasToken' => ! empty( $saved['token'] ) ] );
163 }
164
165 public static function test_token( $token ) {
166 $token = trim( (string) $token );
167
168 if ( '' === $token ) {
169 return [ 'valid' => false, 'message' => __( 'No access token provided', 'b-blocks' ) ];
170 }
171
172 $res = wp_remote_get( add_query_arg(
173 [ 'fields' => 'id,username', 'access_token' => $token ],
174 'https://graph.instagram.com/me'
175 ), [ 'timeout' => 10 ] );
176
177 if ( is_wp_error( $res ) ) {
178 return [ 'valid' => false, 'message' => 'Connection failed: ' . $res->get_error_message() ];
179 }
180
181 $body = json_decode( wp_remote_retrieve_body( $res ), true );
182
183 if ( isset( $body['error']['message'] ) ) {
184 return [ 'valid' => false, 'message' => $body['error']['message'] ];
185 }
186
187 if ( empty( $body['username'] ) ) {
188 return [ 'valid' => false, 'message' => __( 'Instagram did not return an account for this token', 'b-blocks' ) ];
189 }
190
191 self::flush();
192
193 return [ 'valid' => true, 'message' => sprintf( '%s @%s', __( 'Connected as', 'b-blocks' ), $body['username'] ) ];
194 }
195
196 public function feed() {
197 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
198
199 if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
200 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
201 }
202
203 $wanted = sanitize_text_field( wp_unslash( $_POST['account'] ?? '' ) );
204 $limit = min( 100, max( 1, absint( $_POST['limit'] ?? 50 ) ) );
205 $minutes = $this->cache_minutes( $_POST['cache'] ?? 30 );
206 $account = $this->find_account( $wanted );
207
208 $token = $account ? $this->token_for( $account ) : '';
209
210 if ( '' === $token ) {
211 wp_send_json_error( __( 'No Instagram account is connected. Add an access token under Dashboard → Settings → API Integrations.', 'b-blocks' ) );
212 }
213
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
219 if ( false !== $cached ) {
220 wp_send_json_success( $this->take( $cached, $limit ) );
221 }
222
223 $lock = $cache_key . '_lock';
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
244 if ( is_wp_error( $payload ) ) {
245 wp_send_json_error( $payload->get_error_message() );
246 }
247
248 if ( $minutes ) {
249 set_transient( $cache_key, $payload, $minutes * MINUTE_IN_SECONDS );
250 }
251
252 wp_send_json_success( $this->take( $payload, $limit ) );
253 }
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
267 /**
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.
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
353 private function user( $token ) {
354 $sets = [
355 'id,username,media_count,account_type,name,profile_picture_url,followers_count',
356 'id,username,media_count,account_type',
357 ];
358
359 foreach ( $sets as $fields ) {
360 $res = wp_remote_get( add_query_arg(
361 [ 'fields' => $fields, 'access_token' => $token ],
362 'https://graph.instagram.com/me'
363 ), [ 'timeout' => 15 ] );
364
365 if ( is_wp_error( $res ) ) {
366 return $res;
367 }
368
369 $body = json_decode( wp_remote_retrieve_body( $res ), true );
370
371 if ( ! isset( $body['error'] ) ) {
372 return $body;
373 }
374
375 $last = $body;
376 }
377
378 return new \WP_Error( 'b_blocks_instagram', $last['error']['message'] ?? __( 'Instagram rejected the request.', 'b-blocks' ) );
379 }
380
381 private function fetch( $token, $limit ) {
382 $fields = 'id,username,media_type,media_url,thumbnail_url,caption,permalink,timestamp,children{id,media_type,media_url,thumbnail_url,permalink}';
383
384 $user = $this->user( $token );
385
386 if ( is_wp_error( $user ) ) {
387 return $user;
388 }
389
390 $media_res = wp_remote_get( add_query_arg(
391 [ 'fields' => $fields, 'access_token' => $token, 'limit' => $limit ],
392 'https://graph.instagram.com/me/media'
393 ), [ 'timeout' => 15 ] );
394
395 if ( is_wp_error( $media_res ) ) {
396 return $media_res;
397 }
398
399 $media = json_decode( wp_remote_retrieve_body( $media_res ), true );
400
401 if ( isset( $media['error']['message'] ) ) {
402 return new \WP_Error( 'b_blocks_instagram', $media['error']['message'] );
403 }
404
405 return [
406 'user' => [
407 'id' => $user['id'] ?? '',
408 'username' => $user['username'] ?? '',
409 'name' => $user['name'] ?? '',
410 'profile_picture_url' => $user['profile_picture_url'] ?? '',
411 'followers_count' => $user['followers_count'] ?? 0,
412 'mediaCount' => $user['media_count'] ?? 0,
413 'accountType' => $user['account_type'] ?? '',
414 ],
415 'media' => array_values( $media['data'] ?? [] ),
416 ];
417 }
418
419 public function clear_cache() {
420 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
421
422 if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'edit_posts' ) ) {
423 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
424 }
425
426 self::flush();
427
428 wp_send_json_success();
429 }
430
431 public static function flush() {
432 global $wpdb;
433
434 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", '_transient_' . self::CACHE_KEY . '%' ) );
435 }
436 }
437
438 new BBlocksInstagram();