PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.3
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.3
4.6.3 4.6.2 4.6.1 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 1 month ago Database 3 months ago DynamicFieldResolver.php 2 months ago Elementor_Enhancer.php 7 months ago EmbedPress_Core_Installer.php 6 years ago EmbedPress_Notice.php 4 months ago EmbedPress_Plugin_Usage_Tracker.php 2 months ago Extend_CustomPlayer_Controls.php 3 months ago Extend_Elementor_Controls.php 1 year ago FeatureNoticeManager.php 1 month ago FeatureNotices.php 2 weeks ago FeaturePreviewModal.php 1 month ago Feature_Enhancer.php 2 months ago GoogleReviewsAdminPage.php 1 month ago GoogleReviewsApify.php 1 month 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 4 days ago PermalinkHelper.php 11 months ago View_Count_Display.php 1 month ago
GoogleReviewsManaged.php
740 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 * The inbound webhook URL the API POSTs completed results to. This is our
292 * own public REST route; the API signs each POST with the per-job secret so
293 * the route can verify it. Filterable so a site behind a reverse proxy or
294 * with a non-default REST base can advertise the externally-reachable URL.
295 *
296 * Returns '' when the site clearly isn't reachable from the public internet
297 * (http://localhost, *.local, an IP-literal host) — there's no point asking
298 * the API to POST somewhere it can never reach, so we cleanly fall back to
299 * polling + the lazy self-heal pull instead.
300 */
301 public static function webhook_url(): string
302 {
303 $url = rest_url(GoogleReviewsRestController::NS . '/google-reviews/webhook');
304 $host = (string) wp_parse_url($url, PHP_URL_HOST);
305 $unreachable = $host === ''
306 || $host === 'localhost'
307 || $host === '127.0.0.1'
308 || substr($host, -6) === '.local'
309 || substr($host, -8) === '.localhost'
310 || filter_var($host, FILTER_VALIDATE_IP) !== false; // bare IP → usually LAN/dev
311 if ($unreachable) {
312 $url = '';
313 }
314 return (string) apply_filters('embedpress/google_reviews/webhook_url', $url, $host);
315 }
316
317 /**
318 * Surface a 401/403 from the proxy as a single "Disconnected — please
319 * reconnect" status string. Caller passes the parsed body + HTTP code;
320 * returns the message to write into the store, or '' if the response
321 * wasn't an auth failure.
322 */
323 private static function auth_failure_message(int $code, $body): string
324 {
325 if ($code !== 401 && $code !== 403) {
326 return '';
327 }
328 $err = is_array($body) ? (string) ($body['error'] ?? '') : '';
329 if ($err === 'site_mismatch' || $err === 'fingerprint_mismatch') {
330 return __('This site’s connection has expired. Reconnect to EmbedPress API to continue.', 'embedpress');
331 }
332 // missing_token / invalid_token / revoked / banned all map to the
333 // same user action: reconnect.
334 return __('Not connected to EmbedPress API. Open EmbedPress → Google Reviews and click Connect.', 'embedpress');
335 }
336
337 /**
338 * Renderer filter: kicks in only when the prior handler (Apify) didn't
339 * start a job AND no user Apify token is configured. Returns true if
340 * we successfully enqueued (or cache-hit) so the caller skips its
341 * sync ≤5 fallback.
342 */
343 public function start_fetch_job_filter($started, $place_id, $args)
344 {
345 if ($started) {
346 return $started;
347 }
348 // EmbedPress managed scraper WINS by default: it fetches ALL reviews
349 // for free via api.embedpress.com/google-reviews/v1, which is strictly
350 // better than the Apify ≤preview run or the Google Places ≤5 API. We
351 // only fall through to those when the managed path can't run (not
352 // connected, or the proxy refused) — handled inside start_job(), which
353 // returns false on failure so the caller's Apify/Google fallback runs.
354 return self::start_job((string) $place_id, is_array($args) ? $args : []);
355 }
356
357 /**
358 * Single-place sync fallback for the renderer's get_reviews_for_render.
359 * If the proxy has the place CACHED, we get reviews inline — no job
360 * dance. Otherwise enqueue + return null (caller renders empty; cron
361 * polls in the background and the block re-renders when reviews land).
362 */
363 public function pre_fetch_filter($pre, $place_id, $args)
364 {
365 if (is_array($pre)) {
366 return $pre;
367 }
368 // EmbedPress managed scraper WINS by default (all reviews, free) over
369 // both the user's Apify token and a Google key. Try it first: a cache
370 // hit returns reviews inline; a miss enqueues a background job and
371 // returns null so the renderer shows empty until the cron poller
372 // populates the store. Only when the managed path can't run (not
373 // connected / proxy unreachable — try_inline_cache_or_enqueue returns
374 // null) do we fall through to the Apify/Google API paths.
375 $result = self::try_inline_cache_or_enqueue((string) $place_id, is_array($args) ? $args : []);
376 return is_array($result) ? $result : null;
377 }
378
379 /**
380 * Public entry: enqueue a scrape for $place_id at the proxy. If the
381 * proxy reports a CACHE HIT, write the reviews to the store immediately
382 * and return true with status=done. If the proxy queues, store the
383 * job_id + schedule the WP-cron poller.
384 *
385 * Returns true if the job moved forward (cache hit or queued); false
386 * if the proxy refused the request or is unreachable.
387 */
388 public static function start_job(string $place_id, array $args = []): bool
389 {
390 if ($place_id === '') {
391 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('No place selected.', 'embedpress')]);
392 return false;
393 }
394
395 $max = self::resolve_max($args);
396
397 // Refuse to call the proxy if we don't have a token — the admin
398 // hasn't clicked Connect yet. Surface a clear "please connect"
399 // status so the UI can render a "Connect to EmbedPress API" CTA
400 // instead of a confusing HTTP error.
401 if (!self::is_connected()) {
402 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
403 'message' => __('Not connected to EmbedPress API. Click Connect in the Google Reviews settings to start fetching reviews.', 'embedpress'),
404 ]);
405 return false;
406 }
407
408 // A USER-INITIATED REFETCH (the Refetch button) must NEVER come from the
409 // proxy's cache — the whole point of clicking Refetch is "go get fresh
410 // data now". So force refresh=true whenever the caller flags user_refetch
411 // (set by the REST refetch handler), regardless of quick/full mode. This
412 // is also what lets a Refetch replace a poisoned/partial cache row (e.g.
413 // an exhausted=1 row holding only 3 reviews from before a worker fix).
414 // Auto/render-triggered fetches omit the flag and still use the cache.
415 $force_refresh = !empty($args['user_refetch'])
416 || (array_key_exists('incremental', $args) && $args['incremental'] === false);
417
418 $payload = [
419 'place_id' => $place_id,
420 'max' => $max,
421 ];
422 if ($force_refresh) {
423 $payload['refresh'] = true;
424 }
425 // Forward the place's known review total from Google Places search
426 // (userRatingCount). When it's an explicit 0 the API skips the Cloud Run
427 // scrape entirely and returns done(0) — saving a full Chrome execution
428 // on a place we already know is empty. Only send a KNOWN, non-negative
429 // count; -1 (unknown, e.g. a manually pasted Place ID) is omitted so the
430 // API scrapes as before.
431 if (isset($args['review_count']) && (int) $args['review_count'] >= 0) {
432 $payload['review_count'] = (int) $args['review_count'];
433 }
434
435 // PUSH-WEBHOOK opt-in. Generate a per-job secret and hand the API our
436 // inbound webhook URL. On completion the API POSTs the reviews straight
437 // to us (signed with this secret) so we NEVER poll for completion — this
438 // is what removes the clock-based "failed" that used to fire when polling
439 // timed out before a slow scrape finished. The secret is keyed to the
440 // job_id once the 202 comes back (below). If the API can't reach us
441 // (localhost/firewalled), the poll + lazy self-heal still backfill — the
442 // webhook is purely additive, so sending it is always safe.
443 $callback_secret = '';
444 $callback_url = self::webhook_url();
445 if ($callback_url !== '') {
446 // 32 random bytes as hex; re-stored under the real job_id after 202.
447 $callback_secret = function_exists('wp_generate_password')
448 ? wp_generate_password(64, false, false)
449 : bin2hex(random_bytes(32));
450 $payload['callback_url'] = $callback_url;
451 $payload['callback_secret'] = $callback_secret;
452 }
453
454 $response = wp_remote_post(self::endpoint() . '/enqueue.php', [
455 'timeout' => (int) apply_filters('embedpress/google_reviews/managed_enqueue_timeout', 10, $place_id, $args),
456 'headers' => self::auth_headers(),
457 'body' => wp_json_encode($payload),
458 ]);
459
460 if (is_wp_error($response)) {
461 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $response->get_error_message()]);
462 return false;
463 }
464 $code = (int) wp_remote_retrieve_response_code($response);
465 $body = json_decode((string) wp_remote_retrieve_body($response), true);
466
467 // Auth failure: clear stored credentials when the proxy says the
468 // token is invalid/revoked so the next page load shows "Connect"
469 // again instead of looping on a dead token.
470 $auth_msg = self::auth_failure_message($code, $body);
471 if ($auth_msg !== '') {
472 if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) {
473 self::disconnect();
474 }
475 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $auth_msg]);
476 return false;
477 }
478
479 // 429: rate-limited. Surface verbatim so the user understands.
480 if ($code === 429) {
481 $msg = is_array($body) && !empty($body['message']) ? $body['message'] : __('EmbedPress API is busy. Please try again in a minute.', 'embedpress');
482 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $msg]);
483 return false;
484 }
485
486 // 200 + cached=true → inline write, no job needed. Merge (append) rather
487 // than reset+replace so a cached result with fewer reviews can't shrink
488 // the stored count — same never-lose rule as the polled 'done' path.
489 if ($code === 200 && is_array($body) && !empty($body['cached'])) {
490 $reviews = is_array($body['reviews'] ?? null) ? $body['reviews'] : [];
491 $meta = is_array($body['meta'] ?? null) ? $body['meta'] : [];
492 GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true);
493 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, ['message' => null, 'run_id' => null]);
494 return true;
495 }
496
497 // 202 + job_id → queued. Save run_id + start polling.
498 if ($code === 202 && is_array($body) && !empty($body['job_id'])) {
499 // Bind the webhook secret to the real job_id so the inbound webhook
500 // route can verify the HMAC when the API POSTs the result back.
501 if ($callback_secret !== '') {
502 GoogleReviewsStore::store_webhook_secret((string) $body['job_id'], $callback_secret);
503 }
504 // DO NOT reset_reviews here. Wiping the stored set upfront made the
505 // count drop to 0 and "climb back up" during a refetch (570 → 0 →
506 // 430…), and if the new scrape got fewer reviews or failed midway we
507 // LOST the reviews we already had. Instead keep the existing reviews
508 // visible while fetching; the 'done' poll merges the new set in via
509 // append_reviews() (dedup union), so a refetch only ever ADDS or
510 // refreshes — it never loses what we already stored.
511 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [
512 'run_id' => (string) $body['job_id'],
513 // Initial placeholder shown only until the first poll pulls the
514 // worker's real live status ("Looking up your place…", etc.).
515 'message' => __('Starting…', 'embedpress'),
516 ]);
517 // First poll fires in 3s — the worker starts writing the
518 // progress count within ~1.5s of claiming the job, so 3s
519 // gets the user a number on screen almost immediately.
520 $delay = isset($body['poll_after_seconds']) ? max(3, (int) $body['poll_after_seconds']) : 3;
521 self::schedule_poll($place_id, $delay);
522 return true;
523 }
524
525 // Anything else → surface as failure.
526 $msg = is_array($body) && !empty($body['message'])
527 ? (string) $body['message']
528 : __('We couldn’t reach EmbedPress API. Please try again.', 'embedpress');
529 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $msg]);
530 return false;
531 }
532
533 /**
534 * Try a cache-hit pre-fetch synchronously. If the proxy has the place
535 * cached, the response is inline — return it without enqueuing. If
536 * not cached, kick off start_job() to put the place in the queue and
537 * return null (the caller renders empty; cron picks it up).
538 */
539 private static function try_inline_cache_or_enqueue(string $place_id, array $args): ?array
540 {
541 $row = GoogleReviewsStore::get($place_id);
542 if ($row && ($row['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_RUNNING) {
543 return null; // already polling
544 }
545
546 // Cheap probe — same payload as start_job but with max=1 so a cache
547 // miss doesn't end up scraping more than necessary if the proxy uses
548 // requested_max to size the eventual run.
549 $started = self::start_job($place_id, $args);
550 if (!$started) {
551 return null;
552 }
553 // start_job() already wrote to the store on cache hit; pull it back
554 // out and return so the renderer can serve this request inline.
555 $row = GoogleReviewsStore::get($place_id);
556 if ($row && ($row['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_DONE) {
557 return [
558 'reviews' => is_array($row['reviews']) ? $row['reviews'] : [],
559 'meta' => is_array($row['meta']) ? $row['meta'] : [],
560 ];
561 }
562 return null;
563 }
564
565 /**
566 * Schedule a one-off WP-cron tick to poll the proxy's status.
567 * Minimum 3s — anything tighter risks overwhelming WP cron if many
568 * places are running simultaneously, and the worker's progress writes
569 * at ~1.8s cadence so a 3s poll matches that closely.
570 */
571 public static function schedule_poll(string $place_id, int $delay = 3): void
572 {
573 if (!wp_next_scheduled(self::CRON_HOOK, [$place_id])) {
574 wp_schedule_single_event(time() + max(3, $delay), self::CRON_HOOK, [$place_id]);
575 }
576 }
577
578 /**
579 * Cron callback. Polls status.php?job_id=…; when status=done, writes
580 * reviews to the store + finalizes. When status=running, reschedules
581 * itself. When failed, surfaces the proxy's message.
582 */
583 public function poll_job($place_id): void
584 {
585 $place_id = (string) $place_id;
586 $row = GoogleReviewsStore::get($place_id);
587 if (!$row || ($row['fetch_status'] ?? '') !== GoogleReviewsStore::STATUS_RUNNING) {
588 return; // cancelled or already done
589 }
590 $job_id = (string) ($row['fetch_run_id'] ?? '');
591 if ($job_id === '') {
592 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('Something went wrong. Please click Refetch to try again.', 'embedpress')]);
593 return;
594 }
595
596 $response = wp_remote_get(
597 self::endpoint() . '/status.php?' . http_build_query(['job_id' => $job_id]),
598 [
599 'timeout' => 10,
600 'headers' => self::auth_headers(),
601 ]
602 );
603 if (is_wp_error($response)) {
604 // Transient error — retry in a moment. If the proxy is permanently
605 // down, multiple reschedules eventually look like "stuck"; the user
606 // can cancel via the admin Refetch UI. Longer delay on errors so
607 // we don't hammer a down proxy.
608 self::schedule_poll($place_id, 10);
609 return;
610 }
611 $code = (int) wp_remote_retrieve_response_code($response);
612 $body = json_decode((string) wp_remote_retrieve_body($response), true);
613
614 // Auth failure during polling = user disconnected (or token revoked).
615 // Stop the cron loop and surface a clear status — don't reschedule.
616 $auth_msg = self::auth_failure_message($code, $body);
617 if ($auth_msg !== '') {
618 if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) {
619 self::disconnect();
620 }
621 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
622 'message' => $auth_msg,
623 'run_id' => null,
624 ]);
625 return;
626 }
627
628 if ($code === 404) {
629 // Proxy lost the job (cache eviction, DB reset, …). Tell the user.
630 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
631 'message' => __('That fetch attempt timed out. Please click Refetch to try again.', 'embedpress'),
632 'run_id' => null,
633 ]);
634 return;
635 }
636 if ($code !== 200 || !is_array($body)) {
637 self::schedule_poll($place_id, 10);
638 return;
639 }
640
641 $status = (string) ($body['status'] ?? '');
642 switch ($status) {
643 case 'queued':
644 case 'running':
645 // Live progress: surface the proxy's status message so the
646 // admin UI shows the same wording the scraper reports.
647 $msg = !empty($body['message']) ? (string) $body['message'] : __('Fetching reviews…', 'embedpress');
648 $so_far = isset($body['fetched_count']) ? (int) $body['fetched_count'] : 0;
649 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [
650 'message' => $msg,
651 'so_far' => $so_far,
652 ]);
653 // Worker writes progress at ~1.8s per scroll round; 3s
654 // matches that closely so the visible count updates feel
655 // continuous instead of jumpy.
656 self::schedule_poll($place_id, 3);
657 return;
658
659 case 'done':
660 $reviews = is_array($body['reviews'] ?? null) ? $body['reviews'] : [];
661 $meta = is_array($body['meta'] ?? null) ? $body['meta'] : [];
662 GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true);
663 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, [
664 'message' => null,
665 'run_id' => null,
666 ]);
667 return;
668
669 case 'failed':
670 // GATE-RETRY GUARD. Cloud Run retries share one job_id: an early
671 // attempt can hit Google's sign-in gate and report 'failed' while
672 // a LATER attempt is still actively scraping. The proxy surfaces
673 // that as status='failed' but with a live "Fetched N reviews so
674 // far…" message + a rising fetched_count. That is NOT a terminal
675 // failure — the scrape is still climbing — so keep polling and
676 // hold the place in 'running' (the proxy's own progress branch
677 // revives the job; we just mustn't latch 'failed' in the meantime).
678 $prox_msg = (string) ($body['message'] ?? '');
679 $prox_n = isset($body['fetched_count']) ? (int) $body['fetched_count'] : 0;
680 $still_scraping = $prox_n > 0 && stripos($prox_msg, 'fetched') !== false;
681 if ($still_scraping) {
682 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [
683 'message' => $prox_msg,
684 'so_far' => $prox_n,
685 ]);
686 self::schedule_poll($place_id, 3);
687 return;
688 }
689 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
690 'message' => $prox_msg !== '' ? $prox_msg : __('Couldn’t fetch reviews this time. Please try Refetch again in a moment.', 'embedpress'),
691 'run_id' => null,
692 ]);
693 return;
694
695 default:
696 // Unknown status — give the proxy one more chance with a
697 // longer delay so we don't tight-loop on a bad response.
698 self::schedule_poll($place_id, 10);
699 }
700 }
701
702 /**
703 * What `max` to send to the proxy enqueue endpoint.
704 *
705 * Contract with `api.embedpress.com/google-reviews/v1/enqueue.php`:
706 * - 0 means "scrape every review Google exposes" (worker scrolls
707 * until exhausted, capped server-side at MAX_REVIEWS_PER_JOB=1000).
708 * - >0 is a soft cap that the worker honors as a hard stop.
709 *
710 * GLOBAL CAP: 1000 reviews/place max (product + Cloud-Run-free-tier
711 * decision, 2026-06-19). Requested here AND enforced server-side.
712 *
713 * TIER CAP: FREE users get at most FREE_MAX_REVIEWS (10) per place — a
714 * "request all" (0) is rewritten to 10 so free never pulls the full set.
715 * PRO is uncapped (0 = all up to the server ceiling). This is the single
716 * enforcement point: every fetch (add, refetch, render) routes through here.
717 */
718 const FREE_MAX_REVIEWS = 10;
719
720 private static function resolve_max(array $args): int
721 {
722 $is_pro = \EmbedPress\Includes\Classes\Helper::is_pro_active();
723 $free_cap = (int) apply_filters('embedpress/google_reviews/free_max_reviews', self::FREE_MAX_REVIEWS);
724 $requested = isset($args['fetch_max']) ? (int) $args['fetch_max'] : 0;
725
726 if (!$is_pro) {
727 // Free: "all" (0) → free cap; an explicit N → min(N, free cap).
728 return $requested <= 0 ? $free_cap : min($requested, $free_cap);
729 }
730
731 // Pro: "all" stays all (proxy expands to its 1000 ceiling); an explicit
732 // N is bounded by the 1000-review global cap.
733 if ($requested <= 0) {
734 return 0; // "all" — proxy caps at MAX_REVIEWS_PER_JOB (1000)
735 }
736 $ceiling = (int) apply_filters('embedpress/google_reviews/managed_max_ceiling', 1000);
737 return min($requested, $ceiling);
738 }
739 }
740