PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / trunk
bBlocks – Essential Gutenberg Blocks & Patterns Collection vtrunk
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 1.5.4 1.5.5 All 106 releases
b-blocks / includes / Instagram.php

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

322 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
15 namespace BBlocks\Inc;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 class BBlocksInstagram {
22 const OPTION = 'b_blocks_instagram';
23 const CACHE_KEY = 'b_blocks_instagram_feed_';
24
25 public function __construct() {
26 add_action( 'init', [ $this, 'register_option' ] );
27 add_action( 'wp_ajax_bBlocksInstagramFeed', [ $this, 'feed' ] );
28 add_action( 'wp_ajax_nopriv_bBlocksInstagramFeed', [ $this, 'feed' ] );
29 add_action( 'wp_ajax_bBlocksInstagramClearCache', [ $this, 'clear_cache' ] );
30 add_action( 'wp_ajax_bBlocksInstagramGetAccount', [ $this, 'get_account' ] );
31 add_action( 'wp_ajax_bBlocksInstagramSaveAccount', [ $this, 'save_account' ] );
32 add_action( 'wp_enqueue_scripts', [ $this, 'localize' ], 20 );
33 add_action( 'enqueue_block_editor_assets', [ $this, 'localize' ], 20 );
34 }
35
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 public function register_option() {
42 register_setting(
43 'options',
44 self::OPTION,
45 [
46 'type' => 'object',
47 'default' => [ 'accounts' => [] ],
48 'show_in_rest' => false,
49 ]
50 );
51 }
52
53 public function localize() {
54 foreach ( [ 'b-blocks-instagram-view-script', 'b-blocks-index-script' ] as $handle ) {
55 if ( wp_script_is( $handle, 'registered' ) ) {
56 wp_localize_script(
57 $handle,
58 'bBlocksInstagram',
59 [
60 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
61 'nonce' => wp_create_nonce( 'wp_ajax' ),
62 ]
63 );
64 }
65 }
66 }
67
68 private function accounts() {
69 $data = get_option( self::OPTION, [] );
70
71 return isset( $data['accounts'] ) && is_array( $data['accounts'] ) ? $data['accounts'] : [];
72 }
73
74 /** The token saved on Dashboard -> Settings -> API Integrations, if any. */
75 public static function dashboard_token() {
76 $keys = get_option( 'bBlocksApiKeys', [] );
77
78 return is_array( $keys ) ? (string) ( $keys['instagram']['key'] ?? '' ) : '';
79 }
80
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 */
85 private function token_for( $account ) {
86 $dashboard = self::dashboard_token();
87
88 return '' === $dashboard ? (string) ( $account['token'] ?? '' ) : $dashboard;
89 }
90
91 /** Matches the account the block asked for, by username or by id. */
92 private function find_account( $wanted ) {
93 foreach ( $this->accounts() as $account ) {
94 if ( '' === $wanted || ( $account['username'] ?? '' ) === $wanted || (string) ( $account['id'] ?? '' ) === (string) $wanted ) {
95 return $account;
96 }
97 }
98
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' => '' ];
102 }
103
104 /** Only an administrator may see or change which account is connected. */
105 private function guard() {
106 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
107
108 if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) || ! current_user_can( 'manage_options' ) ) {
109 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
110 }
111 }
112
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 public function get_account() {
118 $this->guard();
119
120 $account = $this->accounts()[0] ?? [];
121
122 wp_send_json_success(
123 [
124 'username' => $account['username'] ?? '',
125 'hasToken' => '' !== $this->token_for( $account ),
126 'fromDashboard' => '' !== self::dashboard_token(),
127 ]
128 );
129 }
130
131 public function save_account() {
132 $this->guard();
133
134 $account = $this->accounts()[0] ?? [];
135 $username = sanitize_text_field( wp_unslash( $_POST['username'] ?? '' ) );
136 $token = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
137
138 // An empty token field means "leave the stored one alone", so the account
139 // can be renamed without retyping the credential.
140 $saved = [
141 'id' => $account['id'] ?? '',
142 'username' => $username,
143 'token' => '' === $token ? ( $account['token'] ?? '' ) : $token,
144 ];
145
146 update_option( self::OPTION, [ 'accounts' => '' === $saved['username'] && '' === $saved['token'] ? [] : [ $saved ] ] );
147
148 self::flush();
149
150 wp_send_json_success( [ 'username' => $saved['username'], 'hasToken' => ! empty( $saved['token'] ) ] );
151 }
152
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 public static function test_token( $token ) {
158 $token = trim( (string) $token );
159
160 if ( '' === $token ) {
161 return [ 'valid' => false, 'message' => __( 'No access token provided', 'b-blocks' ) ];
162 }
163
164 $res = wp_remote_get( add_query_arg(
165 [ 'fields' => 'id,username', 'access_token' => $token ],
166 'https://graph.instagram.com/me'
167 ), [ 'timeout' => 10 ] );
168
169 if ( is_wp_error( $res ) ) {
170 return [ 'valid' => false, 'message' => 'Connection failed: ' . $res->get_error_message() ];
171 }
172
173 $body = json_decode( wp_remote_retrieve_body( $res ), true );
174
175 if ( isset( $body['error']['message'] ) ) {
176 return [ 'valid' => false, 'message' => $body['error']['message'] ];
177 }
178
179 if ( empty( $body['username'] ) ) {
180 return [ 'valid' => false, 'message' => __( 'Instagram did not return an account for this token', 'b-blocks' ) ];
181 }
182
183 // A new token must not keep serving the feed the old one fetched.
184 self::flush();
185
186 return [ 'valid' => true, 'message' => sprintf( '%s @%s', __( 'Connected as', 'b-blocks' ), $body['username'] ) ];
187 }
188
189 public function feed() {
190 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
191
192 if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) ) {
193 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
194 }
195
196 $wanted = sanitize_text_field( wp_unslash( $_POST['account'] ?? '' ) );
197 $limit = min( 100, max( 1, absint( $_POST['limit'] ?? 50 ) ) );
198 $minutes = min( 10080, max( 0, absint( $_POST['cache'] ?? 30 ) ) );
199 $account = $this->find_account( $wanted );
200
201 $token = $account ? $this->token_for( $account ) : '';
202
203 if ( '' === $token ) {
204 wp_send_json_error( __( 'No Instagram account is connected. Add an access token under Dashboard → Settings → API Integrations.', 'b-blocks' ) );
205 }
206
207 $cache_key = self::CACHE_KEY . md5( $token . '|' . $limit );
208 $cached = $minutes ? get_transient( $cache_key ) : false;
209
210 if ( false !== $cached ) {
211 wp_send_json_success( $cached );
212 }
213
214 $payload = $this->fetch( $token, $limit );
215
216 if ( is_wp_error( $payload ) ) {
217 wp_send_json_error( $payload->get_error_message() );
218 }
219
220 if ( $minutes ) {
221 set_transient( $cache_key, $payload, $minutes * MINUTE_IN_SECONDS );
222 }
223
224 wp_send_json_success( $payload );
225 }
226
227 /**
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.
231 */
232 private function user( $token ) {
233 $sets = [
234 'id,username,media_count,account_type,name,profile_picture_url,followers_count',
235 'id,username,media_count,account_type',
236 ];
237
238 foreach ( $sets as $fields ) {
239 $res = wp_remote_get( add_query_arg(
240 [ 'fields' => $fields, 'access_token' => $token ],
241 'https://graph.instagram.com/me'
242 ), [ 'timeout' => 15 ] );
243
244 if ( is_wp_error( $res ) ) {
245 return $res;
246 }
247
248 $body = json_decode( wp_remote_retrieve_body( $res ), true );
249
250 if ( ! isset( $body['error'] ) ) {
251 return $body;
252 }
253
254 $last = $body;
255 }
256
257 return new \WP_Error( 'b_blocks_instagram', $last['error']['message'] ?? __( 'Instagram rejected the request.', 'b-blocks' ) );
258 }
259
260 private function fetch( $token, $limit ) {
261 $fields = 'id,username,media_type,media_url,thumbnail_url,caption,permalink,timestamp,children{id,media_type,media_url,thumbnail_url,permalink}';
262
263 $user = $this->user( $token );
264
265 if ( is_wp_error( $user ) ) {
266 return $user;
267 }
268
269 $media_res = wp_remote_get( add_query_arg(
270 [ 'fields' => $fields, 'access_token' => $token, 'limit' => $limit ],
271 'https://graph.instagram.com/me/media'
272 ), [ 'timeout' => 15 ] );
273
274 if ( is_wp_error( $media_res ) ) {
275 return $media_res;
276 }
277
278 $media = json_decode( wp_remote_retrieve_body( $media_res ), true );
279
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 if ( isset( $media['error']['message'] ) ) {
283 return new \WP_Error( 'b_blocks_instagram', $media['error']['message'] );
284 }
285
286 return [
287 'user' => [
288 'id' => $user['id'] ?? '',
289 'username' => $user['username'] ?? '',
290 'name' => $user['name'] ?? '',
291 'profile_picture_url' => $user['profile_picture_url'] ?? '',
292 'followers_count' => $user['followers_count'] ?? 0,
293 'mediaCount' => $user['media_count'] ?? 0,
294 'accountType' => $user['account_type'] ?? '',
295 ],
296 'media' => array_values( $media['data'] ?? [] ),
297 ];
298 }
299
300 public function clear_cache() {
301 $nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) );
302
303 if ( ! wp_verify_nonce( $nonce, 'wp_ajax' ) || ! current_user_can( 'edit_posts' ) ) {
304 wp_send_json_error( __( 'Invalid request.', 'b-blocks' ) );
305 }
306
307 self::flush();
308
309 wp_send_json_success();
310 }
311
312 /** A changed account or token must not keep serving the old feed. */
313 private static function flush() {
314 global $wpdb;
315
316 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- transients have no bulk delete API.
317 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", '_transient_' . self::CACHE_KEY . '%' ) );
318 }
319 }
320
321 new BBlocksInstagram();
322