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