PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.0
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.0
4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / EmbedPress / Includes / Classes / GoogleReviewsManaged.php
embedpress / EmbedPress / Includes / Classes Last commit date
Analytics 2 weeks ago Database 2 months ago DynamicFieldResolver.php 1 month ago Elementor_Enhancer.php 6 months ago EmbedPress_Core_Installer.php 6 years ago EmbedPress_Notice.php 3 months ago EmbedPress_Plugin_Usage_Tracker.php 1 month ago Extend_CustomPlayer_Controls.php 2 months ago Extend_Elementor_Controls.php 1 year ago FeatureNoticeManager.php 4 days ago FeatureNotices.php 4 days ago FeaturePreviewModal.php 4 days ago Feature_Enhancer.php 1 month ago GoogleReviewsAdminPage.php 4 days ago GoogleReviewsApify.php 4 days ago GoogleReviewsManaged.php 4 days ago GoogleReviewsRenderer.php 4 days ago GoogleReviewsRestController.php 4 days ago GoogleReviewsStore.php 4 days ago Helper.php 4 days ago Pdf_Thumbnail_Handler.php 2 months ago PermalinkHelper.php 10 months ago View_Count_Display.php 2 weeks ago
GoogleReviewsManaged.php
661 lines
1 <?php
2
3 namespace EmbedPress\Includes\Classes;
4
5 if (!defined('ABSPATH')) {
6 exit;
7 }
8
9 /**
10 * Hosted-proxy review fetch via api.embedpress.com/google-reviews/v1.
11 *
12 * Lets EmbedPress installs fetch reviews with ZERO user setup — search runs
13 * through the existing managed_search endpoint (`google-places.php`), and
14 * now the review fetch runs through the scraping worker at
15 * `api.embedpress.com/google-reviews/v1`. The site never sees an API key,
16 * never connects an Apify token; the proxy holds both.
17 *
18 * Architecture mirrors GoogleReviewsApify on purpose so the two are
19 * interchangeable from the renderer's POV:
20 *
21 * start_job($place_id, $args)
22 * → POSTs to enqueue.php with the place_id + max
23 * → on cache hit (24h): writes reviews directly to the store, done
24 * → on queued: stores the job_id in fetch_run_id, schedules a WP-cron poll
25 *
26 * poll_job($place_id)
27 * → GETs status.php?job_id=…
28 * → "running" → reschedule
29 * → "done" → write reviews to the store, status=done
30 * → "failed" → set the failed status with the proxy's message
31 *
32 * This class is the LOWEST-priority handler on the `start_fetch_job` and
33 * `pre_fetch` filters — Apify (user's token) wins when connected; managed
34 * runs as the fallback when no user keys exist.
35 */
36 class GoogleReviewsManaged
37 {
38 const CRON_HOOK = 'ep_gr_poll_managed';
39
40 // Where the proxy lives. Filterable so dev/staging environments can
41 // point at a local instance.
42 const DEFAULT_ENDPOINT = 'https://api.embedpress.com/google-reviews/v1';
43
44 // wp_options key holding the Bearer token + binding metadata returned
45 // by api.embedpress.com/google-reviews/v1/connect.php. Shape:
46 // [
47 // 'token' => 'epgr_...',
48 // 'site_id' => '<uuid>',
49 // 'home_url' => 'https://example.com',
50 // 'fingerprint' => '<sha256 hex>',
51 // 'tier' => 'free' | 'pro',
52 // 'connected_at' => 1781700000,
53 // ]
54 // Stored with autoload=no so it doesn't bloat every wp_load_alloptions().
55 const OPT_AUTH = 'embedpress_google_reviews_managed_auth';
56
57 public function __construct()
58 {
59 add_action(self::CRON_HOOK, [$this, 'poll_job'], 10, 1);
60 // Priority 20 = LOWER than Apify (10) and Pro overrides. Apify-with-
61 // token takes precedence; managed kicks in only when nothing else
62 // produces a result.
63 add_filter('embedpress/google_reviews/start_fetch_job', [$this, 'start_fetch_job_filter'], 20, 3);
64 add_filter('embedpress/google_reviews/pre_fetch', [$this, 'pre_fetch_filter'], 20, 3);
65 }
66
67 public static function endpoint(): string
68 {
69 return (string) apply_filters(
70 'embedpress/google_reviews/managed_endpoint',
71 self::DEFAULT_ENDPOINT
72 );
73 }
74
75 /**
76 * Sibling endpoint: api.embedpress.com/google-places.php hosts the
77 * INSTANT review-fetch path (Places Details with reviews) — up to 5
78 * reviews per place returned in ~300ms, no Chrome involved. Reuses the
79 * same Bearer token issued by connect.php.
80 *
81 * We derive the URL from `endpoint()` so a dev-override (filter pointing
82 * at staging) keeps both endpoints in sync.
83 */
84 public static function instant_endpoint(): string
85 {
86 $base = rtrim(self::endpoint(), '/');
87 // endpoint() points at /google-reviews/v1 — pop two segments to get
88 // the host root, then append /google-places.php.
89 $root = preg_replace('#/google-reviews/v1$#', '', $base);
90 $root = rtrim($root, '/');
91 return (string) apply_filters(
92 'embedpress/google_reviews/managed_instant_endpoint',
93 $root . '/google-places.php'
94 );
95 }
96
97 /**
98 * Synchronous, instant 5-review fetch via Google Places API.
99 *
100 * Returns:
101 * ['ok' => true, 'reviews' => [...], 'meta' => [...]] on success
102 * ['ok' => false, 'message' => string] on failure
103 *
104 * Used by the add-place handler so the user sees real reviews on the
105 * place card the moment they add it — no spinner, no background job.
106 * Chrome-based scraping is reserved for "Fetch all" when the user
107 * explicitly wants more than the 5 Google's Place Details returns.
108 */
109 public static function fetch_instant(string $place_id): array
110 {
111 if ($place_id === '') {
112 return ['ok' => false, 'message' => __('Missing place ID.', 'embedpress')];
113 }
114 if (!self::is_connected()) {
115 return ['ok' => false, 'message' => __('Not connected to EmbedPress API.', 'embedpress')];
116 }
117
118 $url = add_query_arg(
119 [
120 'action' => 'reviews',
121 'place_id' => $place_id,
122 ],
123 self::instant_endpoint()
124 );
125
126 $response = wp_remote_get($url, [
127 'timeout' => (int) apply_filters('embedpress/google_reviews/instant_timeout', 8, $place_id),
128 'headers' => self::auth_headers(),
129 ]);
130 if (is_wp_error($response)) {
131 return ['ok' => false, 'message' => $response->get_error_message()];
132 }
133 $code = (int) wp_remote_retrieve_response_code($response);
134 $body = json_decode((string) wp_remote_retrieve_body($response), true);
135
136 // Auth failure: surface as a reconnect prompt, mirroring start_job.
137 $auth_msg = self::auth_failure_message($code, $body);
138 if ($auth_msg !== '') {
139 if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) {
140 self::disconnect();
141 }
142 return ['ok' => false, 'message' => $auth_msg];
143 }
144
145 if ($code !== 200 || !is_array($body)) {
146 $msg = is_array($body) && !empty($body['message'])
147 ? (string) $body['message']
148 : __('Couldn’t fetch reviews this time. Please try again.', 'embedpress');
149 return ['ok' => false, 'message' => $msg];
150 }
151
152 $reviews = isset($body['reviews']) && is_array($body['reviews']) ? array_values($body['reviews']) : [];
153 $meta = isset($body['meta']) && is_array($body['meta']) ? $body['meta'] : [];
154 return ['ok' => true, 'reviews' => $reviews, 'meta' => $meta];
155 }
156
157 // ------------------------------------------------------------------
158 // Connect / disconnect — Bearer-token issuance with api.embedpress.com.
159 // The proxy refuses enqueue / status calls without a valid token bound
160 // to this site's home_url + fingerprint, so the admin clicks Connect
161 // exactly once before the managed scrape path can run.
162 // ------------------------------------------------------------------
163
164 /**
165 * Stable per-install fingerprint sent to the proxy at connect time.
166 * Combines home_url with the site's AUTH_KEY (or an install-time UUID
167 * fallback) so two installs at the same URL still produce different
168 * fingerprints. The proxy stores it and rejects later calls that
169 * present a mismatching fingerprint, making a leaked token useless on
170 * any other install.
171 */
172 public static function compute_fingerprint(): string
173 {
174 $secret = defined('AUTH_KEY') && AUTH_KEY !== '' ? AUTH_KEY : '';
175 if ($secret === '') {
176 // No salts (e.g. fresh wp-config) — fall back to a stable
177 // install-time UUID so the fingerprint still differs per install.
178 $secret = get_option('embedpress_install_uuid');
179 if (!$secret) {
180 $secret = wp_generate_uuid4();
181 update_option('embedpress_install_uuid', $secret, false);
182 }
183 }
184 $material = home_url() . '|' . $secret . '|embedpress-google-reviews';
185 return hash('sha256', $material);
186 }
187
188 /**
189 * Return the stored auth blob, or [] if the site is not connected.
190 * Cheap call — single wp_options lookup.
191 */
192 public static function get_auth(): array
193 {
194 $a = get_option(self::OPT_AUTH);
195 return is_array($a) && !empty($a['token']) ? $a : [];
196 }
197
198 public static function is_connected(): bool
199 {
200 return self::get_auth() !== [];
201 }
202
203 /**
204 * Perform the Connect handshake with the proxy. Stores the returned
205 * token + binding metadata in wp_options on success. Returns
206 * ['ok' => true, ...] on success, ['ok' => false, 'message' => ...]
207 * on failure so the REST controller can surface the reason verbatim.
208 */
209 public static function connect(): array
210 {
211 $payload = [
212 'site_url' => home_url(),
213 'fingerprint' => self::compute_fingerprint(),
214 'admin_email' => get_option('admin_email'),
215 'plugin_version' => defined('EMBEDPRESS_VERSION') ? EMBEDPRESS_VERSION : '',
216 'wp_version' => get_bloginfo('version'),
217 'tier' => \EmbedPress\Includes\Classes\Helper::is_pro_active() ? 'pro' : 'free',
218 ];
219
220 $response = wp_remote_post(self::endpoint() . '/connect.php', [
221 'timeout' => 15,
222 'headers' => [
223 'Content-Type' => 'application/json',
224 'Accept' => 'application/json',
225 ],
226 'body' => wp_json_encode($payload),
227 ]);
228
229 if (is_wp_error($response)) {
230 return ['ok' => false, 'message' => $response->get_error_message()];
231 }
232
233 $code = (int) wp_remote_retrieve_response_code($response);
234 $body = json_decode((string) wp_remote_retrieve_body($response), true);
235
236 if ($code !== 200 || !is_array($body) || empty($body['token'])) {
237 $msg = is_array($body) && !empty($body['message'])
238 ? (string) $body['message']
239 : sprintf(__('Connect failed (HTTP %d).', 'embedpress'), $code);
240 return ['ok' => false, 'message' => $msg];
241 }
242
243 $auth = [
244 'token' => (string) $body['token'],
245 'site_id' => (string) ($body['site_id'] ?? ''),
246 'home_url' => (string) ($body['home_url'] ?? home_url()),
247 'fingerprint' => $payload['fingerprint'],
248 'tier' => (string) ($body['tier'] ?? 'free'),
249 'connected_at' => (int) ($body['issued_at'] ?? time()),
250 ];
251 update_option(self::OPT_AUTH, $auth, false); // autoload=no
252 return ['ok' => true] + $auth;
253 }
254
255 /**
256 * Forget the locally-stored token. The proxy-side row stays put (so an
257 * audit trail remains); a future Connect just rotates the token.
258 */
259 public static function disconnect(): void
260 {
261 delete_option(self::OPT_AUTH);
262 }
263
264 /**
265 * Build the headers every proxy call sends. Adds the Authorization
266 * Bearer + X-EmbedPress-Fingerprint when the site is connected. Falls
267 * back to plain headers (no auth) when not connected so the proxy can
268 * answer with a clear 401 instead of a confusing 400.
269 */
270 private static function auth_headers(): array
271 {
272 $headers = [
273 'Content-Type' => 'application/json',
274 'Accept' => 'application/json',
275 'X-EmbedPress-Site' => home_url(),
276 ];
277 $auth = self::get_auth();
278 if (!empty($auth['token'])) {
279 $headers['Authorization'] = 'Bearer ' . $auth['token'];
280 // Some hosts strip Authorization at FPM; the proxy honors this
281 // alternate header as a fallback so connectivity still works.
282 $headers['X-EmbedPress-Token'] = $auth['token'];
283 $headers['X-EmbedPress-Fingerprint'] = !empty($auth['fingerprint'])
284 ? $auth['fingerprint']
285 : self::compute_fingerprint();
286 }
287 return $headers;
288 }
289
290 /**
291 * Surface a 401/403 from the proxy as a single "Disconnected — please
292 * reconnect" status string. Caller passes the parsed body + HTTP code;
293 * returns the message to write into the store, or '' if the response
294 * wasn't an auth failure.
295 */
296 private static function auth_failure_message(int $code, $body): string
297 {
298 if ($code !== 401 && $code !== 403) {
299 return '';
300 }
301 $err = is_array($body) ? (string) ($body['error'] ?? '') : '';
302 if ($err === 'site_mismatch' || $err === 'fingerprint_mismatch') {
303 return __('This site’s connection has expired. Reconnect to EmbedPress API to continue.', 'embedpress');
304 }
305 // missing_token / invalid_token / revoked / banned all map to the
306 // same user action: reconnect.
307 return __('Not connected to EmbedPress API. Open EmbedPress → Google Reviews and click Connect.', 'embedpress');
308 }
309
310 /**
311 * Renderer filter: kicks in only when the prior handler (Apify) didn't
312 * start a job AND no user Apify token is configured. Returns true if
313 * we successfully enqueued (or cache-hit) so the caller skips its
314 * sync ≤5 fallback.
315 */
316 public function start_fetch_job_filter($started, $place_id, $args)
317 {
318 if ($started) {
319 return $started;
320 }
321 // EmbedPress managed scraper WINS by default: it fetches ALL reviews
322 // for free via api.embedpress.com/google-reviews/v1, which is strictly
323 // better than the Apify ≤preview run or the Google Places ≤5 API. We
324 // only fall through to those when the managed path can't run (not
325 // connected, or the proxy refused) — handled inside start_job(), which
326 // returns false on failure so the caller's Apify/Google fallback runs.
327 return self::start_job((string) $place_id, is_array($args) ? $args : []);
328 }
329
330 /**
331 * Single-place sync fallback for the renderer's get_reviews_for_render.
332 * If the proxy has the place CACHED, we get reviews inline — no job
333 * dance. Otherwise enqueue + return null (caller renders empty; cron
334 * polls in the background and the block re-renders when reviews land).
335 */
336 public function pre_fetch_filter($pre, $place_id, $args)
337 {
338 if (is_array($pre)) {
339 return $pre;
340 }
341 // EmbedPress managed scraper WINS by default (all reviews, free) over
342 // both the user's Apify token and a Google key. Try it first: a cache
343 // hit returns reviews inline; a miss enqueues a background job and
344 // returns null so the renderer shows empty until the cron poller
345 // populates the store. Only when the managed path can't run (not
346 // connected / proxy unreachable — try_inline_cache_or_enqueue returns
347 // null) do we fall through to the Apify/Google API paths.
348 $result = self::try_inline_cache_or_enqueue((string) $place_id, is_array($args) ? $args : []);
349 return is_array($result) ? $result : null;
350 }
351
352 /**
353 * Public entry: enqueue a scrape for $place_id at the proxy. If the
354 * proxy reports a CACHE HIT, write the reviews to the store immediately
355 * and return true with status=done. If the proxy queues, store the
356 * job_id + schedule the WP-cron poller.
357 *
358 * Returns true if the job moved forward (cache hit or queued); false
359 * if the proxy refused the request or is unreachable.
360 */
361 public static function start_job(string $place_id, array $args = []): bool
362 {
363 if ($place_id === '') {
364 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('No place selected.', 'embedpress')]);
365 return false;
366 }
367
368 $max = self::resolve_max($args);
369
370 // Refuse to call the proxy if we don't have a token — the admin
371 // hasn't clicked Connect yet. Surface a clear "please connect"
372 // status so the UI can render a "Connect to EmbedPress API" CTA
373 // instead of a confusing HTTP error.
374 if (!self::is_connected()) {
375 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
376 'message' => __('Not connected to EmbedPress API. Click Connect in the Google Reviews settings to start fetching reviews.', 'embedpress'),
377 ]);
378 return false;
379 }
380
381 // A USER-INITIATED REFETCH (the Refetch button) must NEVER come from the
382 // proxy's cache — the whole point of clicking Refetch is "go get fresh
383 // data now". So force refresh=true whenever the caller flags user_refetch
384 // (set by the REST refetch handler), regardless of quick/full mode. This
385 // is also what lets a Refetch replace a poisoned/partial cache row (e.g.
386 // an exhausted=1 row holding only 3 reviews from before a worker fix).
387 // Auto/render-triggered fetches omit the flag and still use the cache.
388 $force_refresh = !empty($args['user_refetch'])
389 || (array_key_exists('incremental', $args) && $args['incremental'] === false);
390
391 $payload = [
392 'place_id' => $place_id,
393 'max' => $max,
394 ];
395 if ($force_refresh) {
396 $payload['refresh'] = true;
397 }
398
399 $response = wp_remote_post(self::endpoint() . '/enqueue.php', [
400 'timeout' => (int) apply_filters('embedpress/google_reviews/managed_enqueue_timeout', 10, $place_id, $args),
401 'headers' => self::auth_headers(),
402 'body' => wp_json_encode($payload),
403 ]);
404
405 if (is_wp_error($response)) {
406 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $response->get_error_message()]);
407 return false;
408 }
409 $code = (int) wp_remote_retrieve_response_code($response);
410 $body = json_decode((string) wp_remote_retrieve_body($response), true);
411
412 // Auth failure: clear stored credentials when the proxy says the
413 // token is invalid/revoked so the next page load shows "Connect"
414 // again instead of looping on a dead token.
415 $auth_msg = self::auth_failure_message($code, $body);
416 if ($auth_msg !== '') {
417 if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) {
418 self::disconnect();
419 }
420 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $auth_msg]);
421 return false;
422 }
423
424 // 429: rate-limited. Surface verbatim so the user understands.
425 if ($code === 429) {
426 $msg = is_array($body) && !empty($body['message']) ? $body['message'] : __('EmbedPress API is busy. Please try again in a minute.', 'embedpress');
427 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $msg]);
428 return false;
429 }
430
431 // 200 + cached=true → inline write, no job needed. Merge (append) rather
432 // than reset+replace so a cached result with fewer reviews can't shrink
433 // the stored count — same never-lose rule as the polled 'done' path.
434 if ($code === 200 && is_array($body) && !empty($body['cached'])) {
435 $reviews = is_array($body['reviews'] ?? null) ? $body['reviews'] : [];
436 $meta = is_array($body['meta'] ?? null) ? $body['meta'] : [];
437 GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true);
438 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, ['message' => null, 'run_id' => null]);
439 return true;
440 }
441
442 // 202 + job_id → queued. Save run_id + start polling.
443 if ($code === 202 && is_array($body) && !empty($body['job_id'])) {
444 // DO NOT reset_reviews here. Wiping the stored set upfront made the
445 // count drop to 0 and "climb back up" during a refetch (570 → 0 →
446 // 430…), and if the new scrape got fewer reviews or failed midway we
447 // LOST the reviews we already had. Instead keep the existing reviews
448 // visible while fetching; the 'done' poll merges the new set in via
449 // append_reviews() (dedup union), so a refetch only ever ADDS or
450 // refreshes — it never loses what we already stored.
451 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [
452 'run_id' => (string) $body['job_id'],
453 // Initial placeholder shown only until the first poll pulls the
454 // worker's real live status ("Looking up your place…", etc.).
455 'message' => __('Starting…', 'embedpress'),
456 ]);
457 // First poll fires in 3s — the worker starts writing the
458 // progress count within ~1.5s of claiming the job, so 3s
459 // gets the user a number on screen almost immediately.
460 $delay = isset($body['poll_after_seconds']) ? max(3, (int) $body['poll_after_seconds']) : 3;
461 self::schedule_poll($place_id, $delay);
462 return true;
463 }
464
465 // Anything else → surface as failure.
466 $msg = is_array($body) && !empty($body['message'])
467 ? (string) $body['message']
468 : __('We couldn’t reach EmbedPress API. Please try again.', 'embedpress');
469 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $msg]);
470 return false;
471 }
472
473 /**
474 * Try a cache-hit pre-fetch synchronously. If the proxy has the place
475 * cached, the response is inline — return it without enqueuing. If
476 * not cached, kick off start_job() to put the place in the queue and
477 * return null (the caller renders empty; cron picks it up).
478 */
479 private static function try_inline_cache_or_enqueue(string $place_id, array $args): ?array
480 {
481 $row = GoogleReviewsStore::get($place_id);
482 if ($row && ($row['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_RUNNING) {
483 return null; // already polling
484 }
485
486 // Cheap probe — same payload as start_job but with max=1 so a cache
487 // miss doesn't end up scraping more than necessary if the proxy uses
488 // requested_max to size the eventual run.
489 $started = self::start_job($place_id, $args);
490 if (!$started) {
491 return null;
492 }
493 // start_job() already wrote to the store on cache hit; pull it back
494 // out and return so the renderer can serve this request inline.
495 $row = GoogleReviewsStore::get($place_id);
496 if ($row && ($row['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_DONE) {
497 return [
498 'reviews' => is_array($row['reviews']) ? $row['reviews'] : [],
499 'meta' => is_array($row['meta']) ? $row['meta'] : [],
500 ];
501 }
502 return null;
503 }
504
505 /**
506 * Schedule a one-off WP-cron tick to poll the proxy's status.
507 * Minimum 3s — anything tighter risks overwhelming WP cron if many
508 * places are running simultaneously, and the worker's progress writes
509 * at ~1.8s cadence so a 3s poll matches that closely.
510 */
511 public static function schedule_poll(string $place_id, int $delay = 3): void
512 {
513 if (!wp_next_scheduled(self::CRON_HOOK, [$place_id])) {
514 wp_schedule_single_event(time() + max(3, $delay), self::CRON_HOOK, [$place_id]);
515 }
516 }
517
518 /**
519 * Cron callback. Polls status.php?job_id=…; when status=done, writes
520 * reviews to the store + finalizes. When status=running, reschedules
521 * itself. When failed, surfaces the proxy's message.
522 */
523 public function poll_job($place_id): void
524 {
525 $place_id = (string) $place_id;
526 $row = GoogleReviewsStore::get($place_id);
527 if (!$row || ($row['fetch_status'] ?? '') !== GoogleReviewsStore::STATUS_RUNNING) {
528 return; // cancelled or already done
529 }
530 $job_id = (string) ($row['fetch_run_id'] ?? '');
531 if ($job_id === '') {
532 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('Something went wrong. Please click Refetch to try again.', 'embedpress')]);
533 return;
534 }
535
536 $response = wp_remote_get(
537 self::endpoint() . '/status.php?' . http_build_query(['job_id' => $job_id]),
538 [
539 'timeout' => 10,
540 'headers' => self::auth_headers(),
541 ]
542 );
543 if (is_wp_error($response)) {
544 // Transient error — retry in a moment. If the proxy is permanently
545 // down, multiple reschedules eventually look like "stuck"; the user
546 // can cancel via the admin Refetch UI. Longer delay on errors so
547 // we don't hammer a down proxy.
548 self::schedule_poll($place_id, 10);
549 return;
550 }
551 $code = (int) wp_remote_retrieve_response_code($response);
552 $body = json_decode((string) wp_remote_retrieve_body($response), true);
553
554 // Auth failure during polling = user disconnected (or token revoked).
555 // Stop the cron loop and surface a clear status — don't reschedule.
556 $auth_msg = self::auth_failure_message($code, $body);
557 if ($auth_msg !== '') {
558 if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) {
559 self::disconnect();
560 }
561 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
562 'message' => $auth_msg,
563 'run_id' => null,
564 ]);
565 return;
566 }
567
568 if ($code === 404) {
569 // Proxy lost the job (cache eviction, DB reset, …). Tell the user.
570 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
571 'message' => __('That fetch attempt timed out. Please click Refetch to try again.', 'embedpress'),
572 'run_id' => null,
573 ]);
574 return;
575 }
576 if ($code !== 200 || !is_array($body)) {
577 self::schedule_poll($place_id, 10);
578 return;
579 }
580
581 $status = (string) ($body['status'] ?? '');
582 switch ($status) {
583 case 'queued':
584 case 'running':
585 // Live progress: surface the proxy's status message so the
586 // admin UI shows the same wording the scraper reports.
587 $msg = !empty($body['message']) ? (string) $body['message'] : __('Fetching reviews…', 'embedpress');
588 $so_far = isset($body['fetched_count']) ? (int) $body['fetched_count'] : 0;
589 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [
590 'message' => $msg,
591 'so_far' => $so_far,
592 ]);
593 // Worker writes progress at ~1.8s per scroll round; 3s
594 // matches that closely so the visible count updates feel
595 // continuous instead of jumpy.
596 self::schedule_poll($place_id, 3);
597 return;
598
599 case 'done':
600 $reviews = is_array($body['reviews'] ?? null) ? $body['reviews'] : [];
601 $meta = is_array($body['meta'] ?? null) ? $body['meta'] : [];
602 GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true);
603 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, [
604 'message' => null,
605 'run_id' => null,
606 ]);
607 return;
608
609 case 'failed':
610 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
611 'message' => !empty($body['message']) ? (string) $body['message'] : __('Couldn’t fetch reviews this time. Please try Refetch again in a moment.', 'embedpress'),
612 'run_id' => null,
613 ]);
614 return;
615
616 default:
617 // Unknown status — give the proxy one more chance with a
618 // longer delay so we don't tight-loop on a bad response.
619 self::schedule_poll($place_id, 10);
620 }
621 }
622
623 /**
624 * What `max` to send to the proxy enqueue endpoint.
625 *
626 * Contract with `api.embedpress.com/google-reviews/v1/enqueue.php`:
627 * - 0 means "scrape every review Google exposes" (worker scrolls
628 * until exhausted, capped server-side at MAX_REVIEWS_PER_JOB=1000).
629 * - >0 is a soft cap that the worker honors as a hard stop.
630 *
631 * GLOBAL CAP: 1000 reviews/place max (product + Cloud-Run-free-tier
632 * decision, 2026-06-19). Requested here AND enforced server-side.
633 *
634 * TIER CAP: FREE users get at most FREE_MAX_REVIEWS (10) per place — a
635 * "request all" (0) is rewritten to 10 so free never pulls the full set.
636 * PRO is uncapped (0 = all up to the server ceiling). This is the single
637 * enforcement point: every fetch (add, refetch, render) routes through here.
638 */
639 const FREE_MAX_REVIEWS = 10;
640
641 private static function resolve_max(array $args): int
642 {
643 $is_pro = \EmbedPress\Includes\Classes\Helper::is_pro_active();
644 $free_cap = (int) apply_filters('embedpress/google_reviews/free_max_reviews', self::FREE_MAX_REVIEWS);
645 $requested = isset($args['fetch_max']) ? (int) $args['fetch_max'] : 0;
646
647 if (!$is_pro) {
648 // Free: "all" (0) → free cap; an explicit N → min(N, free cap).
649 return $requested <= 0 ? $free_cap : min($requested, $free_cap);
650 }
651
652 // Pro: "all" stays all (proxy expands to its 1000 ceiling); an explicit
653 // N is bounded by the 1000-review global cap.
654 if ($requested <= 0) {
655 return 0; // "all" — proxy caps at MAX_REVIEWS_PER_JOB (1000)
656 }
657 $ceiling = (int) apply_filters('embedpress/google_reviews/managed_max_ceiling', 1000);
658 return min($requested, $ceiling);
659 }
660 }
661