PluginProbe
Gutenify – Visual Site Builder Blocks & Starter Templates / 1.8.0
Gutenify – Visual Site Builder Blocks & Starter Templates v1.8.0
1.9.0 1.8.0 1.7.0 1.6.6 1.6.5 1.6.4 trunk 0.0.1 0.0.2 0.0.3 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 74 releases
gutenify / core / inc / rest-api / class-image-sideload-queue.php

class-image-sideload-queue.php in Gutenify – Visual Site Builder Blocks & Starter Templates 1.8.0, at core/inc/rest-api/class-image-sideload-queue.php

271 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace gutenify;
3
4 // Exit if accessed directly.
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 /**
10 * Background image sideloading for the full demo-site importer.
11 *
12 * Rest_Demo_Importer_V2::create_post() inserts imported posts/pages/
13 * templates immediately with their content as-is (still pointing at the
14 * demo source's remote images), then queues the post here instead of
15 * sideloading inline. This class does the actual downloading afterward, off
16 * the request that's blocking the import wizard's UI, via a WP-Cron-style
17 * non-blocking loopback request (the same mechanism core's spawn_cron()
18 * uses) so it starts immediately rather than waiting on WP-Cron's next
19 * site-visit trigger.
20 *
21 * Single-template/kit import (Gutenify_Rest::get_template_data()) is
22 * intentionally NOT routed through this queue: that endpoint only returns
23 * content for the browser to insert into the currently-open, not-yet-saved
24 * editor session — there is no post row yet to queue or patch, so it stays
25 * synchronous (see Helpers::sideload_remote_images() called inline there).
26 */
27 class Image_Sideload_Queue {
28
29 /**
30 * Class Init.
31 *
32 * @return void
33 */
34 public static function init() {
35 add_action( 'rest_api_init', array( __CLASS__, 'add_endpoint' ) );
36 }
37
38 /**
39 * Add API Endpoints.
40 *
41 * @return void
42 */
43 public static function add_endpoint() {
44 $constants = Helpers::plugin_constants();
45 $plugin_main_slug = $constants['plugin_main_slug'];
46
47 register_rest_route(
48 $plugin_main_slug . '/demo-import/v1',
49 '/process-image-queue',
50 array(
51 'methods' => 'POST',
52 'callback' => array( __CLASS__, 'process_queue' ),
53 'permission_callback' => array( __CLASS__, 'verify_token' ),
54 )
55 );
56 }
57
58 /**
59 * Marks a just-imported post as having remote images pending sideload,
60 * to be picked up by the next queue run.
61 *
62 * @param int $post_id Post ID.
63 * @return void
64 */
65 public static function enqueue_post( $post_id ) {
66 update_post_meta( $post_id, self::meta_key(), '1' );
67 }
68
69 /**
70 * Fires a non-blocking loopback request to kick off (or continue) queue
71 * processing in a separate PHP process, so the caller doesn't wait on it.
72 * Mirrors core's spawn_cron(): a near-zero timeout plus `blocking =>
73 * false` means wp_remote_post() returns almost immediately here while
74 * the request it opened keeps running server-side.
75 *
76 * @return void
77 */
78 public static function trigger_async() {
79 $constants = Helpers::plugin_constants();
80 $plugin_main_slug = $constants['plugin_main_slug'];
81
82 $url = add_query_arg(
83 'token',
84 self::get_secret_token(),
85 rest_url( $plugin_main_slug . '/demo-import/v1/process-image-queue' )
86 );
87
88 wp_remote_post(
89 $url,
90 array(
91 // Matches core's own spawn_cron() convention exactly:
92 // `blocking => false` alone doesn't guarantee this call
93 // returns fast in every environment (measured on this local
94 // Studio dev server: raising this to a few seconds just
95 // made the CALLER wait nearly that long too, instead of
96 // returning sooner — apparently the transport polls up to
97 // `timeout` rather than exiting the moment the request is
98 // sent). A small timeout is the safer default across
99 // hosting environments; it can fail to get the request out
100 // occasionally, which is why this is self-healing — see the
101 // call site in create_post(), which retriggers once per
102 // imported post, and process_queue(), which only clears a
103 // post's pending flag on full success so a missed trigger
104 // just gets retried on the next one.
105 'timeout' => 0.01,
106 'blocking' => false,
107 'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
108 )
109 );
110 }
111
112 /**
113 * Worker: sideloads images for one batch of queued posts, then re-fires
114 * itself (async) if the batch was full, so a large demo import continues
115 * across multiple short-lived requests instead of one long-running one.
116 *
117 * @return \WP_REST_Response
118 */
119 public static function process_queue() {
120 $lock_key = self::lock_key();
121
122 // Another run is already in flight — let it continue; it will pick
123 // up anything queued after it started on its own next batch.
124 if ( get_transient( $lock_key ) ) {
125 return new \WP_REST_Response( array( 'success' => true, 'skipped' => 'locked' ), 200 );
126 }
127
128 set_transient( $lock_key, 1, 2 * MINUTE_IN_SECONDS );
129
130 $constants = Helpers::plugin_constants();
131 $plugin_main_site_domain = $constants['plugin_main_site_domain'];
132 $batch_size = 10;
133
134 $post_ids = get_posts(
135 array(
136 'post_type' => 'any',
137 'post_status' => 'any',
138 'posts_per_page' => $batch_size,
139 'fields' => 'ids',
140 'meta_key' => self::meta_key(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
141 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
142 'no_found_rows' => true,
143 )
144 );
145
146 $processed = 0;
147 $needs_retry = false;
148 $max_retries = 5;
149
150 foreach ( $post_ids as $post_id ) {
151 $post = get_post( $post_id );
152
153 if ( $post ) {
154 $new_content = Helpers::sideload_remote_images( $post->post_content, $plugin_main_site_domain );
155
156 if ( $new_content !== $post->post_content ) {
157 wp_update_post(
158 array(
159 'ID' => $post_id,
160 'post_content' => $new_content,
161 )
162 );
163 }
164
165 // media_sideload_image() can fail for reasons that are
166 // transient (a network blip, the demo source briefly down)
167 // rather than permanent — only clear the pending flag once
168 // nothing is left to retry, so a failed attempt gets picked
169 // up again by the next trigger instead of the image being
170 // silently abandoned as remote forever.
171 if ( Helpers::has_remote_images( $new_content, $plugin_main_site_domain ) ) {
172 $attempts = (int) get_post_meta( $post_id, self::attempts_key(), true ) + 1;
173
174 if ( $attempts >= $max_retries ) {
175 // Given up — leave whatever images remain remote
176 // rather than retrying forever on a permanently
177 // unreachable URL.
178 delete_post_meta( $post_id, self::meta_key() );
179 delete_post_meta( $post_id, self::attempts_key() );
180 } else {
181 update_post_meta( $post_id, self::attempts_key(), $attempts );
182 $needs_retry = true;
183 }
184 } else {
185 delete_post_meta( $post_id, self::meta_key() );
186 delete_post_meta( $post_id, self::attempts_key() );
187 }
188 } else {
189 delete_post_meta( $post_id, self::meta_key() );
190 delete_post_meta( $post_id, self::attempts_key() );
191 }
192
193 ++$processed;
194 }
195
196 delete_transient( $lock_key );
197
198 // Either there may be more queued than this batch could hold, or
199 // something in this batch still needs a retry — either way, another
200 // pass is warranted.
201 if ( count( $post_ids ) === $batch_size || $needs_retry ) {
202 self::trigger_async();
203 }
204
205 return new \WP_REST_Response( array( 'success' => true, 'processed' => $processed ), 200 );
206 }
207
208 /**
209 * Verifies the shared-secret token on an incoming queue-processing
210 * request. This endpoint is only ever called by this site's own
211 * server-side loopback trigger, never a browser, so it's gated by a
212 * per-site HMAC token derived from a WordPress auth salt rather than
213 * `current_user_can()` — a loopback request carries no reliable user
214 * session to check.
215 *
216 * @param \WP_REST_Request $request Request object.
217 * @return bool
218 */
219 public static function verify_token( \WP_REST_Request $request ) {
220 $token = $request->get_param( 'token' );
221
222 return is_string( $token ) && hash_equals( self::get_secret_token(), $token );
223 }
224
225 /**
226 * Per-site secret used to authorize the loopback request without
227 * depending on cookies/user session.
228 *
229 * @return string
230 */
231 private static function get_secret_token() {
232 return hash_hmac( 'sha256', 'gutenify_image_sideload_queue', wp_salt( 'auth' ) );
233 }
234
235 /**
236 * Post meta key flagging a post as having pending remote images.
237 *
238 * @return string
239 */
240 private static function meta_key() {
241 $constants = Helpers::plugin_constants();
242
243 return '_' . $constants['plugin_main_function_prefix'] . '_pending_image_sideload';
244 }
245
246 /**
247 * Post meta key counting failed sideload attempts, so a permanently
248 * unreachable image doesn't get retried forever.
249 *
250 * @return string
251 */
252 private static function attempts_key() {
253 $constants = Helpers::plugin_constants();
254
255 return '_' . $constants['plugin_main_function_prefix'] . '_image_sideload_attempts';
256 }
257
258 /**
259 * Transient key used to prevent overlapping queue runs.
260 *
261 * @return string
262 */
263 private static function lock_key() {
264 $constants = Helpers::plugin_constants();
265
266 return $constants['plugin_main_function_prefix'] . '_image_sideload_lock';
267 }
268 }
269
270 Image_Sideload_Queue::init();
271