PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.4
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.4
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 / GoogleReviewsRestController.php
embedpress / EmbedPress / Includes / Classes Last commit date
Analytics 2 months 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 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 3 weeks ago Helper.php 3 weeks ago Pdf_Thumbnail_Handler.php 3 weeks ago PermalinkHelper.php 1 year ago View_Count_Display.php 2 months ago
GoogleReviewsRestController.php
1160 lines
1 <?php
2
3 namespace EmbedPress\Includes\Classes;
4
5 use WP_REST_Request;
6 use WP_REST_Response;
7 use WP_Error;
8
9 (defined('ABSPATH') && defined('EMBEDPRESS_IS_LOADED')) or die("No direct script access allowed.");
10
11 /**
12 * REST endpoints powering the Google Reviews searchable picker, live preview,
13 * settings save, and cache flush.
14 *
15 * AUTHORIZATION — two tiers, and the split is deliberate:
16 *
17 * can_edit() / `edit_posts` — the block-editor surfaces only: search,
18 * place-counts, preview, per-place status. These are read-only against the
19 * author's own editing session, carry no site-wide side effects, and an
20 * Editor placing a Google Reviews block legitimately needs them. Keeping the
21 * API key server-side is what these gate.
22 *
23 * can_manage() / `manage_options` — anything reading or mutating the global
24 * saved-places library, plugin settings, the managed-proxy connection, or the
25 * cache. `wp_embedpress_gr_places` is a SINGLE SITE-WIDE store — not scoped
26 * per-post, per-author, or per-block-instance — so an add/remove there affects
27 * every page on the site that renders a Google Reviews block. That is an
28 * administrative action, not an authoring one.
29 *
30 * When adding a route, pick the tier by what the handler TOUCHES, not by who
31 * happens to call it from the editor. If it writes to the shared store or
32 * exposes configuration, it is can_manage().
33 */
34 class GoogleReviewsRestController
35 {
36 const NS = 'embedpress/v1';
37
38 public static function register()
39 {
40 register_rest_route(self::NS, '/google-reviews/search', [
41 'methods' => 'GET',
42 'callback' => [__CLASS__, 'search'],
43 // Paid op (managed/Apify/Google search) — exclude Contributors.
44 'permission_callback' => [__CLASS__, 'can_use_paid'],
45 'args' => [
46 'q' => ['type' => 'string', 'required' => true],
47 'session_token' => ['type' => 'string'],
48 ],
49 ]);
50
51 // Second stage of the place picker. Autocomplete carries names only —
52 // no Places API tier returns a rating inside a prediction — so the
53 // suggestion list renders immediately and the �
54 /reviews line is filled
55 // in by this route afterwards. Kept separate from /search so a slow or
56 // failed count lookup can never delay the results themselves.
57 register_rest_route(self::NS, '/google-reviews/place-counts', [
58 'methods' => 'GET',
59 'callback' => [__CLASS__, 'place_counts'],
60 'permission_callback' => [__CLASS__, 'can_edit'],
61 'args' => [
62 'place_ids' => ['type' => 'string', 'required' => true],
63 ],
64 ]);
65
66 register_rest_route(self::NS, '/google-reviews/preview', [
67 'methods' => 'GET',
68 'callback' => [__CLASS__, 'preview'],
69 // Paid op (first-render auto-fetch) — exclude Contributors.
70 'permission_callback' => [__CLASS__, 'can_use_paid'],
71 'args' => [
72 'place_id' => ['type' => 'string', 'required' => true],
73 'place_name' => ['type' => 'string'],
74 'limit' => ['type' => 'integer', 'default' => 5],
75 'min_rating' => ['type' => 'integer', 'default' => 0],
76 'layout' => ['type' => 'string', 'default' => 'list'],
77 'show_photo' => ['type' => 'boolean', 'default' => true],
78 'show_date' => ['type' => 'boolean', 'default' => true],
79 'show_stars' => ['type' => 'boolean', 'default' => true],
80 'show_link' => ['type' => 'boolean', 'default' => false],
81 'show_images' => ['type' => 'boolean', 'default' => true],
82 'columns' => ['type' => 'integer', 'default' => 3],
83 'max_width' => ['type' => 'integer', 'default' => 0],
84 'gap' => ['type' => 'integer', 'default' => 32],
85 'show_arrows' => ['type' => 'boolean', 'default' => true],
86 'show_dots' => ['type' => 'boolean', 'default' => true],
87 'carousel_loop' => ['type' => 'boolean', 'default' => true],
88 'autoplay' => ['type' => 'boolean', 'default' => false],
89 'autoplay_speed' => ['type' => 'number', 'default' => 5],
90 'show_summary' => ['type' => 'boolean', 'default' => true],
91 'show_summary_name' => ['type' => 'boolean', 'default' => true],
92 'show_summary_rating' => ['type' => 'boolean', 'default' => true],
93 'show_summary_stars' => ['type' => 'boolean', 'default' => true],
94 'show_summary_count' => ['type' => 'boolean', 'default' => true],
95 'show_write_review' => ['type' => 'boolean', 'default' => true],
96 'summary_align' => ['type' => 'string', 'default' => 'left'],
97 // Pro controls — declared so WP type-coerces "false"→false (a query
98 // string "false" is otherwise truthy). The free renderer ignores
99 // them; Pro's render filters consume them.
100 'sort' => ['type' => 'string', 'default' => 'newest'],
101 'keyword' => ['type' => 'string', 'default' => ''],
102 'hide_empty' => ['type' => 'boolean', 'default' => false],
103 'load_more' => ['type' => 'boolean', 'default' => false],
104 'theme' => ['type' => 'string', 'default' => 'light'],
105 'accent_color' => ['type' => 'string', 'default' => ''],
106 'schema' => ['type' => 'boolean', 'default' => false],
107 ],
108 ]);
109
110 // PUBLIC pagination endpoint — frontend visitors fetch the next page of
111 // review cards on "Load more" (AJAX). No auth: it only ever returns
112 // already-public review content from the DB store, server-rendered.
113 register_rest_route(self::NS, '/google-reviews/page', [
114 'methods' => 'GET',
115 'callback' => [__CLASS__, 'page'],
116 'permission_callback' => '__return_true',
117 'args' => [
118 'place_id' => ['type' => 'string', 'required' => true],
119 'offset' => ['type' => 'integer', 'default' => 0],
120 'per_page' => ['type' => 'integer', 'default' => 5],
121 'min_rating' => ['type' => 'integer', 'default' => 0],
122 'layout' => ['type' => 'string', 'default' => 'list'],
123 'show_photo' => ['type' => 'boolean', 'default' => true],
124 'show_date' => ['type' => 'boolean', 'default' => true],
125 'show_stars' => ['type' => 'boolean', 'default' => true],
126 'show_images' => ['type' => 'boolean', 'default' => true],
127 // Pro display args (renderer/filters consume what applies).
128 'sort' => ['type' => 'string', 'default' => 'newest'],
129 'keyword' => ['type' => 'string', 'default' => ''],
130 'hide_empty' => ['type' => 'boolean', 'default' => false],
131 'theme' => ['type' => 'string', 'default' => 'light'],
132 'accent_color' => ['type' => 'string', 'default' => ''],
133 'places' => ['type' => 'array', 'default' => []],
134 ],
135 ]);
136
137 register_rest_route(self::NS, '/google-reviews/settings', [
138 [
139 'methods' => 'GET',
140 'callback' => [__CLASS__, 'get_settings'],
141 'permission_callback' => [__CLASS__, 'can_manage'],
142 ],
143 [
144 'methods' => 'POST',
145 'callback' => [__CLASS__, 'save_settings'],
146 'permission_callback' => [__CLASS__, 'can_manage'],
147 'args' => [
148 'api_key' => ['type' => 'string'],
149 'cache_ttl' => ['type' => 'integer'],
150 'apify_token' => ['type' => 'string'],
151 'search_provider' => ['type' => 'string'],
152 ],
153 ],
154 ]);
155
156 register_rest_route(self::NS, '/google-reviews/clear-cache', [
157 'methods' => 'POST',
158 'callback' => [__CLASS__, 'clear_cache'],
159 'permission_callback' => [__CLASS__, 'can_manage'],
160 ]);
161
162 register_rest_route(self::NS, '/google-reviews/verify-key', [
163 'methods' => 'POST',
164 'callback' => [__CLASS__, 'verify_key'],
165 'permission_callback' => [__CLASS__, 'can_manage'],
166 'args' => [
167 'provider' => ['type' => 'string', 'required' => true],
168 'key' => ['type' => 'string', 'required' => true],
169 ],
170 ]);
171
172 // Managed-proxy connect / disconnect / status. The Google Reviews
173 // settings page drives a "Connect to EmbedPress API" button through
174 // these — once connected, the managed scrape path can call
175 // api.embedpress.com without the user wiring their own keys.
176 register_rest_route(self::NS, '/google-reviews/managed/connect', [
177 'methods' => 'POST',
178 'callback' => [__CLASS__, 'managed_connect'],
179 'permission_callback' => [__CLASS__, 'can_manage'],
180 ]);
181 register_rest_route(self::NS, '/google-reviews/managed/disconnect', [
182 'methods' => 'POST',
183 'callback' => [__CLASS__, 'managed_disconnect'],
184 'permission_callback' => [__CLASS__, 'can_manage'],
185 ]);
186 // Admin-only alongside connect/disconnect: this reports the managed-proxy
187 // connection state and endpoint, which is configuration rather than
188 // authoring data.
189 register_rest_route(self::NS, '/google-reviews/managed/status', [
190 'methods' => 'GET',
191 'callback' => [__CLASS__, 'managed_status'],
192 'permission_callback' => [__CLASS__, 'can_manage'],
193 ]);
194
195 // Lightweight per-place fetch-status poll. The block editor uses this to
196 // show a live progress bar while the background Apify job runs.
197 register_rest_route(self::NS, '/google-reviews/status', [
198 'methods' => 'GET',
199 'callback' => [__CLASS__, 'get_status'],
200 'permission_callback' => [__CLASS__, 'can_edit'],
201 'args' => [
202 'place_id' => ['type' => 'string', 'required' => true],
203 ],
204 ]);
205
206 // PUBLIC, read-only variant of the status poll, scoped to a single
207 // place_id. Used by the FRONTEND poller (static/js/google-reviews-status.js)
208 // so a published page that rendered the "loading" placeholder (a place
209 // added but not yet fetched) can self-refresh the moment the background
210 // job finishes — without a manual reload. Returns only non-sensitive
211 // job state (status + review_count); same handler as the editor poll,
212 // which also advances the job (WP-cron is unreliable on quiet pages).
213 register_rest_route(self::NS, '/google-reviews/public-status', [
214 'methods' => 'GET',
215 'callback' => [__CLASS__, 'get_public_status'],
216 'permission_callback' => '__return_true',
217 'args' => [
218 'place_id' => ['type' => 'string', 'required' => true],
219 ],
220 ]);
221
222 // PUSH-WEBHOOK inbound sink. api.embedpress.com POSTs a completed scrape
223 // here so the site never has to poll for completion (which is what used
224 // to fail on a clock when a slow scrape outran the poll deadline). Public
225 // permission_callback because the request comes from the API, not a
226 // logged-in user — authentication is the HMAC signature verified inside
227 // the handler against the per-job secret we issued at enqueue. Without a
228 // valid signature the handler 401s and writes nothing.
229 register_rest_route(self::NS, '/google-reviews/webhook', [
230 'methods' => 'POST',
231 'callback' => [__CLASS__, 'receive_webhook'],
232 'permission_callback' => '__return_true',
233 ]);
234
235 // ADMIN-ONLY. Both verbs touch the global saved-places library:
236 // GET returns it in full AND advances running fetch jobs inline (a side
237 // effect, not a plain read); POST adds/removes entries that render on
238 // every page embedding a Google Reviews block. `edit_posts` is far too
239 // low a bar for that — it is granted to Contributors, who cannot even
240 // publish their own posts, yet could delete another user's saved place
241 // site-wide. Gated on manage_options.
242 register_rest_route(self::NS, '/google-reviews/places', [
243 [
244 'methods' => 'GET',
245 'callback' => [__CLASS__, 'get_places'],
246 'permission_callback' => [__CLASS__, 'can_manage'],
247 ],
248 [
249 'methods' => 'POST',
250 'callback' => [__CLASS__, 'post_places'],
251 // Writes to the SITE-WIDE places library AND queues paid managed
252 // scrapes / Apify runs. Two separate findings land on this route:
253 // fbs-84284 (a Contributor could delete another user's entry from
254 // the shared store) and fbs-83822 (paid ops must exclude
255 // Contributors). can_manage satisfies both — can_use_paid alone
256 // would still admit Authors to a store they do not own, leaving
257 // the reported authorization issue partly open. GET is likewise
258 // can_manage; the editor-facing read surfaces are search/preview.
259 'permission_callback' => [__CLASS__, 'can_manage'],
260 'args' => [
261 'action' => ['type' => 'string', 'required' => true],
262 'place_id' => ['type' => 'string', 'required' => true],
263 'place_name' => ['type' => 'string'],
264 'place_address' => ['type' => 'string'], // location description (saved with the place)
265 // Search-result seed: the place's real total review count +
266 // rating, so the row shows them immediately on add (before the
267 // background fetch finishes).
268 //
269 // 'total' deliberately has NO default. WP copies every arg's
270 // default into the request's defaults bucket during dispatch,
271 // and has_param()/get_param() both read that bucket — so a
272 // default of 0 makes an omitted count indistinguishable from
273 // a search result of "0 reviews". That difference decides
274 // whether the API skips the scrape, so absence must stay
275 // detectable via get_param() === null.
276 'total' => ['type' => 'integer'],
277 'rating' => ['type' => 'number', 'default' => 0],
278 'fetch_max' => ['type' => 'integer', 'default' => 0], // 0 = all up to ceiling
279 'limit' => ['type' => 'integer', 'default' => 100],
280 'sort' => ['type' => 'string', 'default' => 'newest'],
281 // 'quick' (default) = incremental: keep stored reviews, append new ones,
282 // stop when we hit one we already have. Cheap.
283 // 'full' = wipe and re-pull. Surfaces edits/deletes, costs more.
284 'mode' => ['type' => 'string', 'default' => 'quick'],
285 ],
286 ],
287 ]);
288 }
289
290 /**
291 * Block-editor tier. Use ONLY for read-only, side-effect-free routes that an
292 * Editor placing a Google Reviews block genuinely needs (search, counts,
293 * preview, per-place status). Never for anything that writes to the shared
294 * places store or exposes settings — see can_manage().
295 */
296 public static function can_edit()
297 {
298 return current_user_can('edit_posts');
299 }
300
301 /**
302 * Gate for endpoints that trigger PAID backend operations (place search /
303 * preview → managed scrapes, Apify runs, Google API calls).
304 *
305 * `edit_posts` (used by can_edit) is held by Contributors, whose posts await
306 * review — letting them spend real API/scrape budget with no oversight. Paid
307 * ops require `edit_published_posts` instead, which Authors, Editors, and
308 * Admins have but Contributors do not. Filterable so a site can widen/narrow
309 * the bar (e.g. a custom role) without patching core.
310 */
311 public static function can_use_paid()
312 {
313 return (bool) apply_filters(
314 'embedpress/google_reviews/paid_capability',
315 current_user_can('edit_published_posts')
316 );
317 }
318
319 /**
320 * Administrative tier. Required for the global places library, settings,
321 * managed-proxy connection, and cache routes.
322 */
323 public static function can_manage()
324 {
325 return current_user_can('manage_options');
326 }
327
328 /**
329 * Proxy Google Places Autocomplete so the API key stays server-side.
330 */
331 /**
332 * Rating + review count for the place IDs a search just returned.
333 *
334 * Split out from search() so the suggestion list is never blocked on these
335 * lookups: Autocomplete gives names only, so the picker renders results
336 * first and calls this to fill in �
337 /reviews. Always returns 200 with
338 * whatever resolved — a failed lookup leaves those rows without a count
339 * rather than surfacing an error over a list that rendered fine.
340 */
341 public static function place_counts(WP_REST_Request $request)
342 {
343 $ids = array_filter(array_map('trim', explode(',', (string) $request->get_param('place_ids'))));
344 if (!$ids) {
345 return new WP_REST_Response(['counts' => (object) []], 200);
346 }
347
348 // Autocomplete returns at most 5; cap so this can't be used to fan out
349 // arbitrary paid lookups.
350 $ids = array_slice(array_values($ids), 0, 10);
351
352 $counts = GoogleReviewsRenderer::managed_place_counts($ids);
353 if (is_wp_error($counts)) {
354 return new WP_REST_Response(['counts' => (object) []], 200);
355 }
356
357 return new WP_REST_Response(['counts' => (object) $counts], 200);
358 }
359
360 public static function search(WP_REST_Request $request)
361 {
362 $q = trim((string) $request->get_param('q'));
363 if ($q === '' || mb_strlen($q) < 2) {
364 return new WP_REST_Response(['predictions' => []], 200);
365 }
366
367 // Session-token billing (managed proxy only): when the picker passes
368 // the same UUID across each keystroke + the final details call, Google
369 // bills the whole session as ONE event instead of N. Skip the WP-side
370 // transient cache for tokened calls — caching would let a future
371 // session reuse a prior response and break the token-billing link.
372 $session_token = trim((string) $request->get_param('session_token'));
373
374 $provider = GoogleReviewsRenderer::get_search_provider();
375
376 // BACKEND GUARD: search runs through a provider. When the effective
377 // provider is the hosted EmbedPress API ('managed' — the default when
378 // the user has no own Google/Apify key) but the site is NOT connected,
379 // refuse the search outright (a 403). The UI also blocks this, but the
380 // server must enforce it too so a direct REST call can't search without
381 // a connection. A user with their own Google/Apify key is unaffected.
382 if ($provider === 'managed' && GoogleReviewsManaged::get_auth() === []) {
383 return new WP_Error(
384 'embedpress_gr_not_connected',
385 __('Connect to the EmbedPress API to search for places.', 'embedpress'),
386 ['status' => 403]
387 );
388 }
389
390 if ($session_token === '') {
391 $cache_key = 'embedpress_gr_ac_' . $provider . '_' . md5(strtolower($q));
392 $cached = get_transient($cache_key);
393 if (is_array($cached)) {
394 return new WP_REST_Response(['predictions' => $cached, 'provider' => $provider, 'cached' => true], 200);
395 }
396 }
397
398 $apify_error = null;
399 if ($provider === 'managed') {
400 // Hosted-proxy search: zero user setup, EmbedPress's server runs
401 // the session-billed Google Places Autocomplete on the user's
402 // behalf (fast, ~1.4s). This is the DEFAULT in 'auto' mode.
403 $predictions = GoogleReviewsRenderer::managed_search($q, $session_token);
404 // If the proxy is rate-limited / down AND the user happens to have
405 // an Apify token (set up for "fetch all reviews"), fall back to
406 // Apify search so the picker still works. Apify is only a fallback
407 // here — never the default — because its search is a ~20s scrape.
408 if (is_wp_error($predictions) && GoogleReviewsRenderer::get_apify_token() !== '') {
409 $apify = GoogleReviewsRenderer::apify_search($q);
410 if (!is_wp_error($apify)) {
411 $predictions = $apify;
412 }
413 }
414 } elseif ($provider === 'apify') {
415 // Apify-backed place search is FREE (the block's place picker must
416 // work without Pro). Returns a predictions array, or a WP_Error.
417 $predictions = GoogleReviewsRenderer::apify_search($q);
418 if (is_wp_error($predictions)) {
419 // A real Apify timeout is its own actionable failure: tell the
420 // user to retry rather than swallowing it into the Google
421 // fallback (which, with no Google key, would report a misleading
422 // "no provider connected" message). Surface it directly.
423 if ($predictions->get_error_code() === 'embedpress_gr_apify_timeout') {
424 return $predictions;
425 }
426 $apify_error = $predictions;
427 // Out-of-credit / bad-token / unexpected response → try Google
428 // (user key first, then the hosted proxy). If everything fails
429 // we surface $apify_error.
430 $predictions = GoogleReviewsRenderer::autocomplete($q);
431 if (is_wp_error($predictions)) {
432 $predictions = GoogleReviewsRenderer::managed_search($q, $session_token);
433 }
434 }
435 } else {
436 $predictions = GoogleReviewsRenderer::autocomplete($q);
437 }
438
439 if (is_wp_error($predictions)) {
440 // Last-resort fallback: managed proxy. The user picked a provider
441 // and it failed; rather than tell them "no provider", try our own.
442 if ($provider !== 'managed') {
443 $managed = GoogleReviewsRenderer::managed_search($q, $session_token);
444 if (!is_wp_error($managed)) {
445 $predictions = $managed;
446 }
447 }
448 }
449
450 if (is_wp_error($predictions)) {
451 // All paths failed. Prefer the Apify reason if we have one (it's
452 // usually the most actionable "top up / add a key" message).
453 $err = $apify_error instanceof \WP_Error ? $apify_error : $predictions;
454
455 // Don't leak raw transport errors (e.g. "cURL error 7: Failed to
456 // connect to host… port 9920") to the UI — they're meaningless to
457 // users. Replace connection/timeout failures with a friendly note.
458 $msg = (string) $err->get_error_message();
459 if (preg_match('/cURL error|failed to connect|could not connect|connection (timed out|refused)|name or service not known|resolve host|timed out/i', $msg)) {
460 return new \WP_Error(
461 'embedpress_gr_search_unreachable',
462 __('Couldn’t reach the place-search service right now. Please check your connection and try again in a moment.', 'embedpress'),
463 ['status' => 503]
464 );
465 }
466 return $err;
467 }
468
469 // Apify search costs a run per query — cache longer than Google/managed.
470 // Skip caching for session-tokened calls (caching would let a future
471 // session reuse a response and break session-token billing).
472 if ($session_token === '') {
473 $ttl = $provider === 'apify' ? HOUR_IN_SECONDS : 5 * MINUTE_IN_SECONDS;
474 set_transient($cache_key, $predictions, $ttl);
475 }
476 return new WP_REST_Response(['predictions' => $predictions, 'provider' => $provider], 200);
477 }
478
479 /**
480 * Server-rendered preview of the Google Reviews block. The editor & the
481 * settings-page shortcode generator both use this so what they see is
482 * exactly what the frontend will render.
483 */
484 public static function preview(WP_REST_Request $request)
485 {
486 // Query-string params arrive as STRINGS. `(bool) "false"` is true in PHP
487 // (any non-empty string is truthy), so booleans MUST go through
488 // rest_sanitize_boolean(), which maps "false"/"0"/""/false → false.
489 // (Plain (bool) casts were a latent bug — harmless for default-true free
490 // toggles, but it made hide_empty/schema/load_more always-on.)
491 $boolish = static function ($key) use ($request) {
492 return rest_sanitize_boolean($request->get_param($key));
493 };
494 $args = [
495 'place_id' => sanitize_text_field((string) $request->get_param('place_id')),
496 'place_name' => sanitize_text_field((string) $request->get_param('place_name')),
497 'limit' => (int) $request->get_param('limit'),
498 'min_rating' => (int) $request->get_param('min_rating'),
499 'layout' => sanitize_key((string) $request->get_param('layout')),
500 'show_photo' => $boolish('show_photo'),
501 'show_date' => $boolish('show_date'),
502 'show_stars' => $boolish('show_stars'),
503 'show_link' => $boolish('show_link'),
504 'show_images' => $boolish('show_images'),
505 'show_arrows' => $boolish('show_arrows'),
506 'show_dots' => $boolish('show_dots'),
507 'carousel_loop' => $boolish('carousel_loop'),
508 'autoplay' => $boolish('autoplay'),
509 'autoplay_speed' => (float) $request->get_param('autoplay_speed'),
510 'show_summary' => $boolish('show_summary'),
511 'show_summary_name' => $boolish('show_summary_name'),
512 'show_summary_rating' => $boolish('show_summary_rating'),
513 'show_summary_stars' => $boolish('show_summary_stars'),
514 'show_summary_count' => $boolish('show_summary_count'),
515 'show_write_review' => $boolish('show_write_review'),
516 'summary_align' => sanitize_key((string) $request->get_param('summary_align')),
517 'columns' => (int) $request->get_param('columns'),
518 'max_width' => (int) $request->get_param('max_width'),
519 'gap' => (int) $request->get_param('gap'),
520 // Pro args — without these the preview never reflects the Pro controls
521 // (hide-empty, sort, keyword, theme, accent, load-more, schema). They
522 // feed the Pro render filters; the free renderer ignores them. Mirror
523 // Shortcode::do_shortcode_google_reviews().
524 'sort' => sanitize_key((string) $request->get_param('sort')),
525 'keyword' => sanitize_text_field((string) $request->get_param('keyword')),
526 'hide_empty' => $boolish('hide_empty'),
527 'load_more' => $boolish('load_more'),
528 'theme' => sanitize_key((string) $request->get_param('theme')),
529 'accent_color' => sanitize_hex_color((string) $request->get_param('accent_color')) ?: '',
530 'schema' => $boolish('schema'),
531 ];
532 return new WP_REST_Response([
533 'html' => GoogleReviewsRenderer::render($args),
534 'stylesheet' => EMBEDPRESS_URL_ASSETS . 'css/google-reviews.css?ver=' . EMBEDPRESS_VERSION,
535 ], 200);
536 }
537
538 /**
539 * PUBLIC: return one page of rendered review CARDS for "Load more" (AJAX).
540 * The frontend appends the returned HTML and asks for the next offset until
541 * has_more is false. Pure DB read — no network.
542 */
543 public static function page(WP_REST_Request $request)
544 {
545 $boolish = static function ($key) use ($request) {
546 return rest_sanitize_boolean($request->get_param($key));
547 };
548 $places = $request->get_param('places');
549 $args = [
550 'place_id' => sanitize_text_field((string) $request->get_param('place_id')),
551 'min_rating' => (int) $request->get_param('min_rating'),
552 'layout' => sanitize_key((string) $request->get_param('layout')),
553 'show_photo' => $boolish('show_photo'),
554 'show_date' => $boolish('show_date'),
555 'show_stars' => $boolish('show_stars'),
556 'show_images' => $boolish('show_images'),
557 // Pro filter args (ignored by free renderer/filters when inactive).
558 'sort' => sanitize_key((string) $request->get_param('sort')),
559 'keyword' => sanitize_text_field((string) $request->get_param('keyword')),
560 'hide_empty' => $boolish('hide_empty'),
561 'theme' => sanitize_key((string) $request->get_param('theme')),
562 'accent_color' => sanitize_hex_color((string) $request->get_param('accent_color')) ?: '',
563 'places' => is_array($places) ? array_map('sanitize_text_field', $places) : [],
564 ];
565 $offset = max(0, (int) $request->get_param('offset'));
566 $per_page = max(1, min(50, (int) $request->get_param('per_page')));
567
568 $page = GoogleReviewsRenderer::render_page($args, $offset, $per_page);
569 return new WP_REST_Response($page, 200);
570 }
571
572 public static function get_settings()
573 {
574 return new WP_REST_Response([
575 'api_key_configured' => GoogleReviewsRenderer::get_api_key() !== '',
576 'api_key_masked' => self::mask_key(GoogleReviewsRenderer::get_api_key()),
577 'cache_ttl' => GoogleReviewsRenderer::get_cache_ttl(),
578 'api_mode' => GoogleReviewsRenderer::get_api_mode(),
579 'apify_token_configured' => GoogleReviewsRenderer::get_apify_token() !== '',
580 'apify_token_masked' => self::mask_key(GoogleReviewsRenderer::get_apify_token()),
581 'search_provider' => (string) get_option(GoogleReviewsRenderer::OPT_SEARCH_PROVIDER, 'auto'),
582 'search_provider_active' => GoogleReviewsRenderer::get_search_provider(),
583 ], 200);
584 }
585
586 public static function save_settings(WP_REST_Request $request)
587 {
588 $api_key = $request->get_param('api_key');
589 if (is_string($api_key) && $api_key !== '') {
590 // Allow a sentinel "***" to mean "leave the key alone" so the
591 // masked-display flow doesn't accidentally wipe it.
592 if (trim($api_key) !== '***') {
593 $previous = GoogleReviewsRenderer::get_api_key();
594 update_option(GoogleReviewsRenderer::OPT_API_KEY, sanitize_text_field($api_key));
595 // Key changed → invalidate the cached API variant so the next
596 // call re-probes which endpoint family this key is enabled for.
597 if (trim($api_key) !== $previous) {
598 GoogleReviewsRenderer::set_api_mode('auto');
599 }
600 }
601 } elseif ($api_key === '') {
602 delete_option(GoogleReviewsRenderer::OPT_API_KEY);
603 GoogleReviewsRenderer::set_api_mode('auto');
604 }
605
606 $ttl = $request->get_param('cache_ttl');
607 if ($ttl !== null) {
608 $ttl = (int) $ttl;
609 if ($ttl > 0) {
610 update_option(GoogleReviewsRenderer::OPT_CACHE_TTL, $ttl);
611 }
612 }
613
614 // Apify token (Pro "fetch all reviews" provider). "***" sentinel = keep.
615 $apify = $request->get_param('apify_token');
616 if (is_string($apify) && $apify !== '') {
617 if (trim($apify) !== '***') {
618 update_option(GoogleReviewsRenderer::OPT_APIFY_TOKEN, sanitize_text_field($apify));
619 }
620 } elseif ($apify === '') {
621 delete_option(GoogleReviewsRenderer::OPT_APIFY_TOKEN);
622 }
623
624 // Search provider preference: auto | google | apify.
625 $sp = $request->get_param('search_provider');
626 if (is_string($sp) && $sp !== '') {
627 $sp = in_array($sp, ['auto', 'google', 'apify'], true) ? $sp : 'auto';
628 update_option(GoogleReviewsRenderer::OPT_SEARCH_PROVIDER, $sp);
629 }
630
631 return self::get_settings();
632 }
633
634 /**
635 * Live-verify a key/token before it's saved, so the user gets immediate
636 * feedback ("connected" vs "that key didn't work"). Google → a tiny
637 * autocomplete probe; Apify → the token's /users/me endpoint.
638 */
639 public static function verify_key(WP_REST_Request $request)
640 {
641 $provider = sanitize_key((string) $request->get_param('provider'));
642 $key = trim((string) $request->get_param('key'));
643 if ($key === '') {
644 return new WP_REST_Response(['valid' => false, 'message' => __('No key provided.', 'embedpress')], 200);
645 }
646
647 if ($provider === 'apify') {
648 $res = wp_remote_get('https://api.apify.com/v2/users/me?token=' . rawurlencode($key), ['timeout' => 12]);
649 if (is_wp_error($res)) {
650 return new WP_REST_Response(['valid' => false, 'message' => $res->get_error_message()], 200);
651 }
652 $code = (int) wp_remote_retrieve_response_code($res);
653 if ($code === 200) {
654 $body = json_decode(wp_remote_retrieve_body($res), true);
655 $name = $body['data']['username'] ?? '';
656 return new WP_REST_Response(['valid' => true, 'message' => $name ? sprintf(__('Verified (Apify user: %s).', 'embedpress'), $name) : __('Verified.', 'embedpress')], 200);
657 }
658 return new WP_REST_Response(['valid' => false, 'message' => __('Apify rejected this token. Check it in your Apify account → Integrations.', 'embedpress')], 200);
659 }
660
661 // Google: probe Places autocomplete with this exact key (don't touch the
662 // saved option). A successful or zero-results status = the key works.
663 $valid = GoogleReviewsRenderer::verify_google_key($key);
664 if (is_wp_error($valid)) {
665 return new WP_REST_Response(['valid' => false, 'message' => $valid->get_error_message()], 200);
666 }
667 return new WP_REST_Response(['valid' => true, 'message' => __('Verified.', 'embedpress')], 200);
668 }
669
670 public static function clear_cache()
671 {
672 $deleted = GoogleReviewsRenderer::clear_cache();
673 return new WP_REST_Response(['deleted' => $deleted], 200);
674 }
675
676 /**
677 * Connect this install to api.embedpress.com/google-reviews/v1. POSTs
678 * the home_url + fingerprint and stores the Bearer token returned by
679 * the proxy. Idempotent — calling again rotates the token.
680 */
681 public static function managed_connect()
682 {
683 $result = GoogleReviewsManaged::connect();
684 if (empty($result['ok'])) {
685 return new WP_Error(
686 'embedpress_gr_managed_connect_failed',
687 isset($result['message']) ? (string) $result['message'] : __('Connect failed.', 'embedpress'),
688 ['status' => 502]
689 );
690 }
691 return new WP_REST_Response(self::managed_status_payload(), 200);
692 }
693
694 public static function managed_disconnect()
695 {
696 GoogleReviewsManaged::disconnect();
697 return new WP_REST_Response(self::managed_status_payload(), 200);
698 }
699
700 public static function managed_status()
701 {
702 return new WP_REST_Response(self::managed_status_payload(), 200);
703 }
704
705 /**
706 * Shape consumed by the React settings UI: { connected, home_url,
707 * site_id, tier, connected_at }. The token itself is NEVER returned —
708 * only the boolean + binding metadata. Anyone with edit_posts can read
709 * this to know whether to show "Connect" or "Disconnect" affordances.
710 */
711 private static function managed_status_payload(): array
712 {
713 $auth = GoogleReviewsManaged::get_auth();
714 // `would_send` mirrors the payload connect.php will receive — used by
715 // the Connect button on the admin page to show the user exactly what
716 // will leave their server (site URL, admin email, install fingerprint)
717 // BEFORE they click. Honesty over magic-button UX.
718 $would_send = [
719 'site_url' => home_url(),
720 'admin_email' => (string) get_option('admin_email'),
721 ];
722 return [
723 'connected' => $auth !== [],
724 'home_url' => $auth['home_url'] ?? home_url(),
725 'site_id' => $auth['site_id'] ?? '',
726 'tier' => $auth['tier'] ?? '',
727 'connected_at' => isset($auth['connected_at']) ? (int) $auth['connected_at'] : 0,
728 'endpoint' => GoogleReviewsManaged::endpoint(),
729 'would_send' => $would_send,
730 ];
731 }
732
733 /**
734 * Return all saved places from the DB store (the global library), each with
735 * its fetch status so the picker/manager can show "saved / N reviews / last
736 * fetched". Shape stays back-compatible: { places: [...], saved: [...] } —
737 * `saved` mirrors `places` so the existing block picker keeps working.
738 */
739 public static function get_places()
740 {
741 // DRIVE RUNNING JOBS INLINE. The admin UI polls THIS endpoint every 2s
742 // while any place is fetching. Advancing each running job's poll here
743 // (instead of relying on WP-cron, which is unreliable on a quiet admin
744 // page) is what surfaces the worker's live progress — "Looking up your
745 // place…", "Fetched N reviews so far…" — and finalizes the job to done.
746 // Without this, fetch_message stays frozen at the initial placeholder
747 // ("Fetching reviews…") until cron eventually fires. Wrapped per-place
748 // so one bad poll can't break the whole list.
749 foreach (GoogleReviewsStore::all() as $row) {
750 if (($row['fetch_status'] ?? '') !== GoogleReviewsStore::STATUS_RUNNING) {
751 continue;
752 }
753 try {
754 (new GoogleReviewsManaged())->poll_job($row['place_id']);
755 } catch (\Throwable $e) {
756 // Ignore — fall through and return the current store state.
757 }
758 }
759
760 return new WP_REST_Response(self::places_payload(), 200);
761 }
762
763 /**
764 * Per-place fetch status — polled by the block editor for its progress bar.
765 * Returns the live counters without the full reviews payload.
766 */
767 /**
768 * PUSH-WEBHOOK sink — api.embedpress.com POSTs a completed scrape here.
769 *
770 * This is the piece that makes completion PUSH-based: instead of the site
771 * polling status.php and racing a clock (the old "timed out → failed" bug),
772 * the API delivers the finished reviews the moment the scrape ends and we
773 * write them straight into the store, flipping fetch_status to done.
774 *
775 * Auth is the HMAC signature, NOT a WP capability — the caller is a server,
776 * not a logged-in user. We recompute sha256_hmac(rawBody, per-job-secret)
777 * and constant-time compare against X-EP-GR-Signature. The secret was issued
778 * at enqueue and is known only to us and the API, so a leaked webhook URL
779 * alone can't forge reviews. Unknown/expired job → 401, write nothing.
780 */
781 public static function receive_webhook(WP_REST_Request $request)
782 {
783 $raw = (string) $request->get_body();
784 $body = json_decode($raw, true);
785 if (!is_array($body)) {
786 return new WP_REST_Response(['ok' => false, 'error' => 'bad_json'], 400);
787 }
788
789 $job_id = trim((string) ($body['job_id'] ?? ''));
790 $place_id = trim((string) ($body['place_id'] ?? ''));
791 if ($job_id === '' || $place_id === '') {
792 return new WP_REST_Response(['ok' => false, 'error' => 'missing_ids'], 400);
793 }
794
795 // Verify the signature against the per-job secret we stashed at enqueue.
796 $secret = GoogleReviewsStore::get_webhook_secret($job_id);
797 if ($secret === '') {
798 // No secret on file: job unknown here, already consumed, or expired.
799 // Reject rather than trust an unsigned/foreign POST.
800 return new WP_REST_Response(['ok' => false, 'error' => 'unknown_job'], 401);
801 }
802 $header = (string) $request->get_header('x_ep_gr_signature');
803 $got = (stripos($header, 'sha256=') === 0) ? substr($header, 7) : $header;
804 $expected = hash_hmac('sha256', $raw, $secret);
805 if ($got === '' || !hash_equals($expected, $got)) {
806 return new WP_REST_Response(['ok' => false, 'error' => 'bad_signature'], 401);
807 }
808
809 // Signature good — this is a genuine result from the API. Guard against a
810 // place_id that doesn't match a job we actually issued: only write if the
811 // place exists in our store (the enqueue always adds it first).
812 if (!GoogleReviewsStore::exists($place_id)) {
813 // Nothing to attach to — accept (2xx so the API marks it delivered)
814 // but don't create phantom rows from a webhook alone.
815 GoogleReviewsStore::clear_webhook_secret($job_id);
816 return new WP_REST_Response(['ok' => true, 'stored' => false], 200);
817 }
818
819 $status = (string) ($body['status'] ?? 'done');
820 $reviews = is_array($body['reviews'] ?? null) ? $body['reviews'] : [];
821 $meta = is_array($body['meta'] ?? null) ? $body['meta'] : [];
822
823 if ($status === 'failed' && empty($reviews)) {
824 // A genuine failure with no salvage set. Mark failed but DON'T wipe
825 // any reviews we already have (append_reviews never shrinks; here we
826 // only touch job state) — matches the store's never-lose contract.
827 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [
828 'message' => (string) ($meta['message'] ?? __('The review fetch could not be completed.', 'embedpress')),
829 'run_id' => null,
830 ]);
831 } else {
832 // done (with or without reviews) → merge in + finalize. Same write the
833 // poll 'done' path does, so behaviour is identical whether the result
834 // arrived by push or by poll.
835 GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true);
836 GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, [
837 'message' => null,
838 'run_id' => null,
839 ]);
840 }
841
842 // One-shot secret: consume it so a replayed POST can't rewrite the row.
843 GoogleReviewsStore::clear_webhook_secret($job_id);
844
845 return new WP_REST_Response(['ok' => true, 'stored' => true, 'status' => $status], 200);
846 }
847
848 public static function get_status(WP_REST_Request $request)
849 {
850 $place_id = trim((string) $request->get_param('place_id'));
851
852 // DRIVE THE POLL INLINE. Job progress (running → done) is normally
853 // advanced by a WP-cron callback (poll_job), but WP-cron only fires on
854 // site traffic and is unreliable on a quiet admin page — so a place
855 // can sit at "running" in the UI forever even though the proxy
856 // finished seconds ago (the "stuck spinner" bug). Since the admin UI
857 // polls THIS endpoint every few seconds, we advance the job here too:
858 // each status poll checks the proxy and finalizes when done. WP-cron
859 // stays as a backup for when no one is watching the page.
860 if ($place_id !== '') {
861 $pre = GoogleReviewsStore::get($place_id);
862 if ($pre && ($pre['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_RUNNING) {
863 try {
864 (new GoogleReviewsManaged())->poll_job($place_id);
865 } catch (\Throwable $e) {
866 // Never let a poll error break the status read — fall
867 // through and return whatever the store currently has.
868 }
869 }
870 }
871
872 $row = $place_id !== '' ? GoogleReviewsStore::get($place_id) : null;
873 if (!$row) {
874 return new WP_REST_Response([
875 'place_id' => $place_id,
876 'fetch_status' => 'idle',
877 'review_count' => 0,
878 'fetched_so_far' => 0,
879 'fetch_message' => null,
880 'exists' => false,
881 ], 200);
882 }
883 return new WP_REST_Response([
884 'place_id' => $place_id,
885 'fetch_status' => $row['fetch_status'] ?? 'idle',
886 'review_count' => (int) $row['review_count'],
887 'fetched_so_far' => isset($row['fetched_so_far']) ? (int) $row['fetched_so_far'] : 0,
888 'fetch_message' => $row['fetch_message'] ?? null,
889 'last_fetched_at' => $row['last_fetched_at'] ?? null,
890 'exists' => true,
891 ], 200);
892 }
893
894 /**
895 * PUBLIC per-place status poll for the frontend "loading" placeholder.
896 * Reuses get_status() (which also advances a running job, since WP-cron is
897 * unreliable on quiet pages) but returns ONLY non-sensitive job state, and
898 * only for a place that already exists in the store — an unknown place_id
899 * returns idle/0 without touching the network, so the endpoint can't be
900 * abused to enqueue arbitrary fetches.
901 */
902 public static function get_public_status(WP_REST_Request $request)
903 {
904 $place_id = trim((string) $request->get_param('place_id'));
905 if ($place_id === '' || !GoogleReviewsStore::exists($place_id)) {
906 return new WP_REST_Response([
907 'place_id' => $place_id,
908 'fetch_status' => 'idle',
909 'review_count' => 0,
910 'ready' => false,
911 ], 200);
912 }
913
914 // Delegate to the editor handler so the job is advanced identically.
915 $full = self::get_status($request);
916 $data = $full instanceof WP_REST_Response ? $full->get_data() : (array) $full;
917
918 $status = (string) ($data['fetch_status'] ?? 'idle');
919 $count = (int) ($data['review_count'] ?? 0);
920 // "ready" = the poller should stop and reload the block: the job is done
921 // (or failed) and no longer running/queued. The frontend reloads on
922 // done-with-reviews; on failed/done-empty it just stops polling.
923 $ready = !in_array($status, [GoogleReviewsStore::STATUS_RUNNING, GoogleReviewsStore::STATUS_QUEUED], true);
924
925 return new WP_REST_Response([
926 'place_id' => $place_id,
927 'fetch_status' => $status,
928 'review_count' => $count,
929 'ready' => $ready,
930 ], 200);
931 }
932
933 /**
934 * Mutate the global places store. action ∈ {add, remove, refresh}.
935 * Legacy actions {recent, save, unsave} are mapped to add/remove so the
936 * existing block picker continues to work.
937 *
938 * add → lookup-first insert (no re-add/re-fetch if already saved)
939 * remove → delete the place entry
940 * refresh → re-fetch this place's reviews into the store (network)
941 */
942 public static function post_places(WP_REST_Request $request)
943 {
944 $action = sanitize_key((string) $request->get_param('action'));
945 $place_id = sanitize_text_field((string) $request->get_param('place_id'));
946 $place_name = sanitize_text_field((string) $request->get_param('place_name'));
947 // Location description (the search result's secondary_text) — saved so the
948 // saved-place lists can show it without a fetch.
949 $place_address = sanitize_text_field((string) $request->get_param('place_address'));
950
951 if ($place_id === '') {
952 return new WP_Error('embedpress_gr_missing_place', __('place_id is required.', 'embedpress'), ['status' => 400]);
953 }
954
955 // Map legacy picker actions onto the store.
956 if ($action === 'recent' || $action === 'save') {
957 $action = 'add';
958 } elseif ($action === 'unsave') {
959 $action = 'remove';
960 }
961
962 switch ($action) {
963 case 'add':
964 // Free plan supports ONE place in the global library; Pro is
965 // unlimited. Re-adding a place that's already saved is always
966 // fine (idempotent). A NET-NEW place is rejected for free users
967 // once the library already holds a different place — this is the
968 // server-side backstop for the settings-page crown upsell, so the
969 // cap holds regardless of how the add is attempted.
970 if (!Helper::is_pro_active() && !GoogleReviewsStore::get($place_id)) {
971 $existing = GoogleReviewsStore::all();
972 if (is_array($existing) && count($existing) >= 1) {
973 return new WP_Error(
974 'embedpress_gr_pro_required',
975 __('The free plan supports one place. Upgrade to EmbedPress Pro to add unlimited places.', 'embedpress'),
976 ['status' => 403]
977 );
978 }
979 }
980 // Lookup-first: if already saved, this just returns the existing
981 // row — no duplicate, no fetch.
982 $is_new = !GoogleReviewsStore::get($place_id);
983 GoogleReviewsStore::add($place_id, $place_name, $place_address);
984
985 // Seed the search result's real total review count + rating into
986 // the place meta so the row shows the actual numbers right away,
987 // before the background fetch completes. Only seed when missing
988 // (don't clobber a fetched/refreshed value).
989 // Distinguish "search reported 0 reviews" (an explicit 0 we can
990 // act on — skip the scrape) from "no count was sent" (unknown →
991 // scrape normally).
992 //
993 // has_param() cannot make this call: WP populates the request's
994 // defaults bucket for every registered arg during dispatch, and
995 // has_param() counts that bucket, so it returns true even when
996 // the client sent nothing. 'total' is therefore registered
997 // without a default, leaving get_param() to return null when —
998 // and only when — the count is genuinely absent.
999 $raw_total = $request->get_param('total');
1000 $count_known = (null !== $raw_total);
1001 $seed_total = (int) $raw_total;
1002 $seed_rating = (float) $request->get_param('rating');
1003 if ($seed_total > 0 || $seed_rating > 0) {
1004 GoogleReviewsStore::seed_meta($place_id, [
1005 'total' => $seed_total,
1006 'rating' => $seed_rating,
1007 ]);
1008 }
1009
1010 // ON ADD — WORKER FIRST (hybrid):
1011 // 1. Try the EmbedPress worker (start_job → enqueue.php). If
1012 // the place was scraped recently it returns a CACHE HIT and
1013 // reviews land in the store instantly; otherwise it queues a
1014 // Chrome scrape, marks the place "running", and a WP-cron
1015 // poller imports the result. Either way NO paid Google
1016 // Places Details call is made. This also lets API-missing
1017 // places (new/unverified listings, pasted as a Maps URL →
1018 // CID/feature handle) get reviews the API can't return.
1019 // 2. FALLBACK — only if the worker can't move forward (not
1020 // connected / unreachable / refused) do we fetch the first
1021 // 5 via Google Places Details so the card still shows
1022 // something. This keeps the worker as the primary path per
1023 // the "scrape on add, not Places API" contract.
1024 if ($is_new) {
1025 $job_args = [
1026 'fetch_all' => true, // worker pulls up to the ceiling
1027 'fetch_max' => 0, // 0 = all up to MAX_REVIEWS_PER_JOB
1028 'sort' => 'newest',
1029 'places' => [],
1030 // Google Places SEARCH already told us how many reviews
1031 // this place has (userRatingCount). Pass it to the worker
1032 // enqueue so the API can skip a full Chrome scrape when the
1033 // count is 0 — no point spending ~90s on a Cloud Run
1034 // instance to confirm a place we already know is empty.
1035 // Only forward a KNOWN count (search sent 'total'); -1 =
1036 // unknown (e.g. a manually pasted Place ID) → scrape.
1037 'review_count' => $count_known ? $seed_total : -1,
1038 ];
1039 $started = (bool) apply_filters('embedpress/google_reviews/start_fetch_job', false, $place_id, $job_args);
1040
1041 if (!$started && GoogleReviewsManaged::is_connected()) {
1042 // Worker unavailable → Places Details fallback (≤5).
1043 $instant = GoogleReviewsManaged::fetch_instant($place_id);
1044 if (!empty($instant['ok'])) {
1045 $reviews = is_array($instant['reviews'] ?? null) ? $instant['reviews'] : [];
1046 $meta = is_array($instant['meta'] ?? null) ? $instant['meta'] : [];
1047 if ($reviews || $meta) {
1048 GoogleReviewsStore::reset_reviews($place_id);
1049 GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true);
1050 GoogleReviewsStore::set_job(
1051 $place_id,
1052 GoogleReviewsStore::STATUS_DONE,
1053 ['message' => null, 'run_id' => null]
1054 );
1055 }
1056 }
1057 // Don't fail the add if the sub-fetch fails — the place is
1058 // saved and the user can hit Refetch.
1059 }
1060 }
1061 break;
1062 case 'remove':
1063 GoogleReviewsStore::remove($place_id);
1064 break;
1065 case 'refresh':
1066 case 'refetch':
1067 // Provider routing for refetch:
1068 // - 'google' → synchronous ≤5 API fetch (no background job).
1069 // - 'apify' → Pro background batched job via Apify (fetch_all=true).
1070 // - 'managed' → hosted scraping proxy at api.embedpress.com,
1071 // also a background job (fetch_all=true).
1072 // Both background providers hook the start_fetch_job filter;
1073 // GoogleReviewsApify wins at priority 10 when a user token is
1074 // present, GoogleReviewsManaged falls in at priority 20.
1075 $provider = GoogleReviewsRenderer::get_search_provider();
1076 $fetch_all = in_array($provider, ['apify', 'managed'], true);
1077 $fetch_max = (int) $request->get_param('fetch_max'); // 0 = all
1078 $mode = sanitize_key((string) $request->get_param('mode'));
1079 $incremental = ($mode === 'quick' || $mode === ''); // default to quick
1080 $job_args = [
1081 'fetch_all' => $fetch_all,
1082 'fetch_max' => max(0, $fetch_max),
1083 'sort' => sanitize_key((string) $request->get_param('sort')) ?: 'newest',
1084 'places' => [],
1085 'incremental' => $incremental,
1086 // This is a USER-INITIATED refetch (the Refetch button) — it
1087 // must NEVER be served from the proxy's cache. Forces a fresh
1088 // scrape regardless of quick/full mode. (Auto/render fetches
1089 // omit this flag and still use the cache.)
1090 'user_refetch' => true,
1091 ];
1092
1093 // Pro "fetch all" runs as a BACKGROUND batched job (Apify async +
1094 // cron) to avoid the 300s sync timeout on large places. Pro hooks
1095 // this filter to start the job and returns true; the hosted
1096 // scraping proxy (GoogleReviewsManaged) also hooks it at the
1097 // lowest priority so it kicks in for users with no Apify token
1098 // and no Google key. Falls through to a synchronous ≤5 API fetch
1099 // when the active provider is plain Google.
1100 $started = $fetch_all
1101 ? (bool) apply_filters('embedpress/google_reviews/start_fetch_job', false, $place_id, $job_args)
1102 : false;
1103
1104 if (!$started) {
1105 $fetched = GoogleReviewsRenderer::fetch_into_store($place_id, $job_args);
1106 if (is_wp_error($fetched)) {
1107 return $fetched;
1108 }
1109 }
1110 break;
1111 default:
1112 return new WP_Error('embedpress_gr_bad_action', __('Unknown action.', 'embedpress'), ['status' => 400]);
1113 }
1114
1115 return new WP_REST_Response(self::places_payload(), 200);
1116 }
1117
1118 /**
1119 * Build the places list payload from the DB store.
1120 */
1121 private static function places_payload(): array
1122 {
1123 $rows = GoogleReviewsStore::all();
1124 $places = array_map(function ($r) {
1125 return [
1126 'place_id' => $r['place_id'],
1127 'place_name' => $r['place_name'] ?: ($r['meta']['name'] ?? ''),
1128 // Location description (address) for the saved-place lists.
1129 'address' => (string) ($r['meta']['address'] ?? ''),
1130 'review_count' => (int) $r['review_count'],
1131 'rating' => isset($r['meta']['rating']) ? (float) $r['meta']['rating'] : 0,
1132 'total' => isset($r['meta']['total']) ? (int) $r['meta']['total'] : 0,
1133 'source' => $r['source'] ?? '',
1134 'last_fetched_at' => $r['last_fetched_at'] ?? null,
1135 // Background-fetch job state (Pro Apify batched fetch).
1136 'fetch_status' => $r['fetch_status'] ?? 'idle',
1137 'fetch_message' => $r['fetch_message'] ?? null,
1138 'fetched_so_far' => isset($r['fetched_so_far']) ? (int) $r['fetched_so_far'] : 0,
1139 ];
1140 }, $rows);
1141
1142 // `saved` kept as an alias of the full list for the legacy block picker;
1143 // `recent` left empty (the store has no recency concept — everything is
1144 // a saved entry now).
1145 return [
1146 'places' => $places,
1147 'saved' => $places,
1148 'recent' => [],
1149 ];
1150 }
1151
1152 private static function mask_key(string $key): string
1153 {
1154 if ($key === '') return '';
1155 $len = mb_strlen($key);
1156 if ($len <= 6) return str_repeat('*', $len);
1157 return mb_substr($key, 0, 4) . str_repeat('*', max(0, $len - 8)) . mb_substr($key, -4);
1158 }
1159 }
1160