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