PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.0
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.0
4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / EmbedPress / Includes / Classes / GoogleReviewsRestController.php
embedpress / EmbedPress / Includes / Classes Last commit date
Analytics 2 weeks ago Database 2 months ago DynamicFieldResolver.php 1 month ago Elementor_Enhancer.php 6 months ago EmbedPress_Core_Installer.php 6 years ago EmbedPress_Notice.php 3 months ago EmbedPress_Plugin_Usage_Tracker.php 1 month ago Extend_CustomPlayer_Controls.php 2 months ago Extend_Elementor_Controls.php 1 year ago FeatureNoticeManager.php 4 days ago FeatureNotices.php 4 days ago FeaturePreviewModal.php 4 days ago Feature_Enhancer.php 1 month ago GoogleReviewsAdminPage.php 4 days ago GoogleReviewsApify.php 4 days ago GoogleReviewsManaged.php 4 days ago GoogleReviewsRenderer.php 4 days ago GoogleReviewsRestController.php 4 days ago GoogleReviewsStore.php 4 days ago Helper.php 4 days ago Pdf_Thumbnail_Handler.php 2 months ago PermalinkHelper.php 10 months ago View_Count_Display.php 2 weeks ago
GoogleReviewsRestController.php
928 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 register_rest_route(self::NS, '/google-reviews/preview', [
33 'methods' => 'GET',
34 'callback' => [__CLASS__, 'preview'],
35 'permission_callback' => [__CLASS__, 'can_edit'],
36 'args' => [
37 'place_id' => ['type' => 'string', 'required' => true],
38 'place_name' => ['type' => 'string'],
39 'limit' => ['type' => 'integer', 'default' => 5],
40 'min_rating' => ['type' => 'integer', 'default' => 0],
41 'layout' => ['type' => 'string', 'default' => 'list'],
42 'show_photo' => ['type' => 'boolean', 'default' => true],
43 'show_date' => ['type' => 'boolean', 'default' => true],
44 'show_stars' => ['type' => 'boolean', 'default' => true],
45 'show_link' => ['type' => 'boolean', 'default' => false],
46 'show_images' => ['type' => 'boolean', 'default' => true],
47 'columns' => ['type' => 'integer', 'default' => 3],
48 'max_width' => ['type' => 'integer', 'default' => 0],
49 'gap' => ['type' => 'integer', 'default' => 32],
50 'show_arrows' => ['type' => 'boolean', 'default' => true],
51 'show_dots' => ['type' => 'boolean', 'default' => true],
52 'carousel_loop' => ['type' => 'boolean', 'default' => true],
53 'autoplay' => ['type' => 'boolean', 'default' => false],
54 'autoplay_speed' => ['type' => 'number', 'default' => 5],
55 'show_summary' => ['type' => 'boolean', 'default' => true],
56 'show_summary_name' => ['type' => 'boolean', 'default' => true],
57 'show_summary_rating' => ['type' => 'boolean', 'default' => true],
58 'show_summary_stars' => ['type' => 'boolean', 'default' => true],
59 'show_summary_count' => ['type' => 'boolean', 'default' => true],
60 'show_write_review' => ['type' => 'boolean', 'default' => true],
61 'summary_align' => ['type' => 'string', 'default' => 'left'],
62 // Pro controls — declared so WP type-coerces "false"→false (a query
63 // string "false" is otherwise truthy). The free renderer ignores
64 // them; Pro's render filters consume them.
65 'sort' => ['type' => 'string', 'default' => 'newest'],
66 'keyword' => ['type' => 'string', 'default' => ''],
67 'hide_empty' => ['type' => 'boolean', 'default' => false],
68 'load_more' => ['type' => 'boolean', 'default' => false],
69 'theme' => ['type' => 'string', 'default' => 'light'],
70 'accent_color' => ['type' => 'string', 'default' => ''],
71 'schema' => ['type' => 'boolean', 'default' => false],
72 ],
73 ]);
74
75 // PUBLIC pagination endpoint — frontend visitors fetch the next page of
76 // review cards on "Load more" (AJAX). No auth: it only ever returns
77 // already-public review content from the DB store, server-rendered.
78 register_rest_route(self::NS, '/google-reviews/page', [
79 'methods' => 'GET',
80 'callback' => [__CLASS__, 'page'],
81 'permission_callback' => '__return_true',
82 'args' => [
83 'place_id' => ['type' => 'string', 'required' => true],
84 'offset' => ['type' => 'integer', 'default' => 0],
85 'per_page' => ['type' => 'integer', 'default' => 5],
86 'min_rating' => ['type' => 'integer', 'default' => 0],
87 'layout' => ['type' => 'string', 'default' => 'list'],
88 'show_photo' => ['type' => 'boolean', 'default' => true],
89 'show_date' => ['type' => 'boolean', 'default' => true],
90 'show_stars' => ['type' => 'boolean', 'default' => true],
91 'show_images' => ['type' => 'boolean', 'default' => true],
92 // Pro display args (renderer/filters consume what applies).
93 'sort' => ['type' => 'string', 'default' => 'newest'],
94 'keyword' => ['type' => 'string', 'default' => ''],
95 'hide_empty' => ['type' => 'boolean', 'default' => false],
96 'theme' => ['type' => 'string', 'default' => 'light'],
97 'accent_color' => ['type' => 'string', 'default' => ''],
98 'places' => ['type' => 'array', 'default' => []],
99 ],
100 ]);
101
102 register_rest_route(self::NS, '/google-reviews/settings', [
103 [
104 'methods' => 'GET',
105 'callback' => [__CLASS__, 'get_settings'],
106 'permission_callback' => [__CLASS__, 'can_manage'],
107 ],
108 [
109 'methods' => 'POST',
110 'callback' => [__CLASS__, 'save_settings'],
111 'permission_callback' => [__CLASS__, 'can_manage'],
112 'args' => [
113 'api_key' => ['type' => 'string'],
114 'cache_ttl' => ['type' => 'integer'],
115 'apify_token' => ['type' => 'string'],
116 'search_provider' => ['type' => 'string'],
117 ],
118 ],
119 ]);
120
121 register_rest_route(self::NS, '/google-reviews/clear-cache', [
122 'methods' => 'POST',
123 'callback' => [__CLASS__, 'clear_cache'],
124 'permission_callback' => [__CLASS__, 'can_manage'],
125 ]);
126
127 register_rest_route(self::NS, '/google-reviews/verify-key', [
128 'methods' => 'POST',
129 'callback' => [__CLASS__, 'verify_key'],
130 'permission_callback' => [__CLASS__, 'can_manage'],
131 'args' => [
132 'provider' => ['type' => 'string', 'required' => true],
133 'key' => ['type' => 'string', 'required' => true],
134 ],
135 ]);
136
137 // Managed-proxy connect / disconnect / status. The Google Reviews
138 // settings page drives a "Connect to EmbedPress API" button through
139 // these — once connected, the managed scrape path can call
140 // api.embedpress.com without the user wiring their own keys.
141 register_rest_route(self::NS, '/google-reviews/managed/connect', [
142 'methods' => 'POST',
143 'callback' => [__CLASS__, 'managed_connect'],
144 'permission_callback' => [__CLASS__, 'can_manage'],
145 ]);
146 register_rest_route(self::NS, '/google-reviews/managed/disconnect', [
147 'methods' => 'POST',
148 'callback' => [__CLASS__, 'managed_disconnect'],
149 'permission_callback' => [__CLASS__, 'can_manage'],
150 ]);
151 register_rest_route(self::NS, '/google-reviews/managed/status', [
152 'methods' => 'GET',
153 'callback' => [__CLASS__, 'managed_status'],
154 'permission_callback' => [__CLASS__, 'can_edit'],
155 ]);
156
157 // Lightweight per-place fetch-status poll. The block editor uses this to
158 // show a live progress bar while the background Apify job runs.
159 register_rest_route(self::NS, '/google-reviews/status', [
160 'methods' => 'GET',
161 'callback' => [__CLASS__, 'get_status'],
162 'permission_callback' => [__CLASS__, 'can_edit'],
163 'args' => [
164 'place_id' => ['type' => 'string', 'required' => true],
165 ],
166 ]);
167
168 // PUBLIC, read-only variant of the status poll, scoped to a single
169 // place_id. Used by the FRONTEND poller (static/js/google-reviews-status.js)
170 // so a published page that rendered the "loading" placeholder (a place
171 // added but not yet fetched) can self-refresh the moment the background
172 // job finishes — without a manual reload. Returns only non-sensitive
173 // job state (status + review_count); same handler as the editor poll,
174 // which also advances the job (WP-cron is unreliable on quiet pages).
175 register_rest_route(self::NS, '/google-reviews/public-status', [
176 'methods' => 'GET',
177 'callback' => [__CLASS__, 'get_public_status'],
178 'permission_callback' => '__return_true',
179 'args' => [
180 'place_id' => ['type' => 'string', 'required' => true],
181 ],
182 ]);
183
184 register_rest_route(self::NS, '/google-reviews/places', [
185 [
186 'methods' => 'GET',
187 'callback' => [__CLASS__, 'get_places'],
188 'permission_callback' => [__CLASS__, 'can_edit'],
189 ],
190 [
191 'methods' => 'POST',
192 'callback' => [__CLASS__, 'post_places'],
193 'permission_callback' => [__CLASS__, 'can_edit'],
194 'args' => [
195 'action' => ['type' => 'string', 'required' => true],
196 'place_id' => ['type' => 'string', 'required' => true],
197 'place_name' => ['type' => 'string'],
198 'place_address' => ['type' => 'string'], // location description (saved with the place)
199 // Search-result seed: the place's real total review count +
200 // rating, so the row shows them immediately on add (before the
201 // background fetch finishes). 0 = not provided.
202 'total' => ['type' => 'integer', 'default' => 0],
203 'rating' => ['type' => 'number', 'default' => 0],
204 'fetch_max' => ['type' => 'integer', 'default' => 0], // 0 = all up to ceiling
205 'limit' => ['type' => 'integer', 'default' => 100],
206 'sort' => ['type' => 'string', 'default' => 'newest'],
207 // 'quick' (default) = incremental: keep stored reviews, append new ones,
208 // stop when we hit one we already have. Cheap.
209 // 'full' = wipe and re-pull. Surfaces edits/deletes, costs more.
210 'mode' => ['type' => 'string', 'default' => 'quick'],
211 ],
212 ],
213 ]);
214 }
215
216 public static function can_edit()
217 {
218 return current_user_can('edit_posts');
219 }
220
221 public static function can_manage()
222 {
223 return current_user_can('manage_options');
224 }
225
226 /**
227 * Proxy Google Places Autocomplete so the API key stays server-side.
228 */
229 public static function search(WP_REST_Request $request)
230 {
231 $q = trim((string) $request->get_param('q'));
232 if ($q === '' || mb_strlen($q) < 2) {
233 return new WP_REST_Response(['predictions' => []], 200);
234 }
235
236 // Session-token billing (managed proxy only): when the picker passes
237 // the same UUID across each keystroke + the final details call, Google
238 // bills the whole session as ONE event instead of N. Skip the WP-side
239 // transient cache for tokened calls — caching would let a future
240 // session reuse a prior response and break the token-billing link.
241 $session_token = trim((string) $request->get_param('session_token'));
242
243 $provider = GoogleReviewsRenderer::get_search_provider();
244
245 // BACKEND GUARD: search runs through a provider. When the effective
246 // provider is the hosted EmbedPress API ('managed' — the default when
247 // the user has no own Google/Apify key) but the site is NOT connected,
248 // refuse the search outright (a 403). The UI also blocks this, but the
249 // server must enforce it too so a direct REST call can't search without
250 // a connection. A user with their own Google/Apify key is unaffected.
251 if ($provider === 'managed' && GoogleReviewsManaged::get_auth() === []) {
252 return new WP_Error(
253 'embedpress_gr_not_connected',
254 __('Connect to the EmbedPress API to search for places.', 'embedpress'),
255 ['status' => 403]
256 );
257 }
258
259 if ($session_token === '') {
260 $cache_key = 'embedpress_gr_ac_' . $provider . '_' . md5(strtolower($q));
261 $cached = get_transient($cache_key);
262 if (is_array($cached)) {
263 return new WP_REST_Response(['predictions' => $cached, 'provider' => $provider, 'cached' => true], 200);
264 }
265 }
266
267 $apify_error = null;
268 if ($provider === 'managed') {
269 // Hosted-proxy search: zero user setup, EmbedPress's server runs
270 // the session-billed Google Places Autocomplete on the user's
271 // behalf (fast, ~1.4s). This is the DEFAULT in 'auto' mode.
272 $predictions = GoogleReviewsRenderer::managed_search($q, $session_token);
273 // If the proxy is rate-limited / down AND the user happens to have
274 // an Apify token (set up for "fetch all reviews"), fall back to
275 // Apify search so the picker still works. Apify is only a fallback
276 // here — never the default — because its search is a ~20s scrape.
277 if (is_wp_error($predictions) && GoogleReviewsRenderer::get_apify_token() !== '') {
278 $apify = GoogleReviewsRenderer::apify_search($q);
279 if (!is_wp_error($apify)) {
280 $predictions = $apify;
281 }
282 }
283 } elseif ($provider === 'apify') {
284 // Apify-backed place search is FREE (the block's place picker must
285 // work without Pro). Returns a predictions array, or a WP_Error.
286 $predictions = GoogleReviewsRenderer::apify_search($q);
287 if (is_wp_error($predictions)) {
288 // A real Apify timeout is its own actionable failure: tell the
289 // user to retry rather than swallowing it into the Google
290 // fallback (which, with no Google key, would report a misleading
291 // "no provider connected" message). Surface it directly.
292 if ($predictions->get_error_code() === 'embedpress_gr_apify_timeout') {
293 return $predictions;
294 }
295 $apify_error = $predictions;
296 // Out-of-credit / bad-token / unexpected response → try Google
297 // (user key first, then the hosted proxy). If everything fails
298 // we surface $apify_error.
299 $predictions = GoogleReviewsRenderer::autocomplete($q);
300 if (is_wp_error($predictions)) {
301 $predictions = GoogleReviewsRenderer::managed_search($q, $session_token);
302 }
303 }
304 } else {
305 $predictions = GoogleReviewsRenderer::autocomplete($q);
306 }
307
308 if (is_wp_error($predictions)) {
309 // Last-resort fallback: managed proxy. The user picked a provider
310 // and it failed; rather than tell them "no provider", try our own.
311 if ($provider !== 'managed') {
312 $managed = GoogleReviewsRenderer::managed_search($q, $session_token);
313 if (!is_wp_error($managed)) {
314 $predictions = $managed;
315 }
316 }
317 }
318
319 if (is_wp_error($predictions)) {
320 // All paths failed. Prefer the Apify reason if we have one (it's
321 // usually the most actionable "top up / add a key" message).
322 $err = $apify_error instanceof \WP_Error ? $apify_error : $predictions;
323
324 // Don't leak raw transport errors (e.g. "cURL error 7: Failed to
325 // connect to host… port 9920") to the UI — they're meaningless to
326 // users. Replace connection/timeout failures with a friendly note.
327 $msg = (string) $err->get_error_message();
328 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)) {
329 return new \WP_Error(
330 'embedpress_gr_search_unreachable',
331 __('Couldn’t reach the place-search service right now. Please check your connection and try again in a moment.', 'embedpress'),
332 ['status' => 503]
333 );
334 }
335 return $err;
336 }
337
338 // Apify search costs a run per query — cache longer than Google/managed.
339 // Skip caching for session-tokened calls (caching would let a future
340 // session reuse a response and break session-token billing).
341 if ($session_token === '') {
342 $ttl = $provider === 'apify' ? HOUR_IN_SECONDS : 5 * MINUTE_IN_SECONDS;
343 set_transient($cache_key, $predictions, $ttl);
344 }
345 return new WP_REST_Response(['predictions' => $predictions, 'provider' => $provider], 200);
346 }
347
348 /**
349 * Server-rendered preview of the Google Reviews block. The editor & the
350 * settings-page shortcode generator both use this so what they see is
351 * exactly what the frontend will render.
352 */
353 public static function preview(WP_REST_Request $request)
354 {
355 // Query-string params arrive as STRINGS. `(bool) "false"` is true in PHP
356 // (any non-empty string is truthy), so booleans MUST go through
357 // rest_sanitize_boolean(), which maps "false"/"0"/""/false → false.
358 // (Plain (bool) casts were a latent bug — harmless for default-true free
359 // toggles, but it made hide_empty/schema/load_more always-on.)
360 $boolish = static function ($key) use ($request) {
361 return rest_sanitize_boolean($request->get_param($key));
362 };
363 $args = [
364 'place_id' => sanitize_text_field((string) $request->get_param('place_id')),
365 'place_name' => sanitize_text_field((string) $request->get_param('place_name')),
366 'limit' => (int) $request->get_param('limit'),
367 'min_rating' => (int) $request->get_param('min_rating'),
368 'layout' => sanitize_key((string) $request->get_param('layout')),
369 'show_photo' => $boolish('show_photo'),
370 'show_date' => $boolish('show_date'),
371 'show_stars' => $boolish('show_stars'),
372 'show_link' => $boolish('show_link'),
373 'show_images' => $boolish('show_images'),
374 'show_arrows' => $boolish('show_arrows'),
375 'show_dots' => $boolish('show_dots'),
376 'carousel_loop' => $boolish('carousel_loop'),
377 'autoplay' => $boolish('autoplay'),
378 'autoplay_speed' => (float) $request->get_param('autoplay_speed'),
379 'show_summary' => $boolish('show_summary'),
380 'show_summary_name' => $boolish('show_summary_name'),
381 'show_summary_rating' => $boolish('show_summary_rating'),
382 'show_summary_stars' => $boolish('show_summary_stars'),
383 'show_summary_count' => $boolish('show_summary_count'),
384 'show_write_review' => $boolish('show_write_review'),
385 'summary_align' => sanitize_key((string) $request->get_param('summary_align')),
386 'columns' => (int) $request->get_param('columns'),
387 'max_width' => (int) $request->get_param('max_width'),
388 'gap' => (int) $request->get_param('gap'),
389 // Pro args — without these the preview never reflects the Pro controls
390 // (hide-empty, sort, keyword, theme, accent, load-more, schema). They
391 // feed the Pro render filters; the free renderer ignores them. Mirror
392 // Shortcode::do_shortcode_google_reviews().
393 'sort' => sanitize_key((string) $request->get_param('sort')),
394 'keyword' => sanitize_text_field((string) $request->get_param('keyword')),
395 'hide_empty' => $boolish('hide_empty'),
396 'load_more' => $boolish('load_more'),
397 'theme' => sanitize_key((string) $request->get_param('theme')),
398 'accent_color' => sanitize_hex_color((string) $request->get_param('accent_color')) ?: '',
399 'schema' => $boolish('schema'),
400 ];
401 return new WP_REST_Response([
402 'html' => GoogleReviewsRenderer::render($args),
403 'stylesheet' => EMBEDPRESS_URL_ASSETS . 'css/google-reviews.css?ver=' . EMBEDPRESS_VERSION,
404 ], 200);
405 }
406
407 /**
408 * PUBLIC: return one page of rendered review CARDS for "Load more" (AJAX).
409 * The frontend appends the returned HTML and asks for the next offset until
410 * has_more is false. Pure DB read — no network.
411 */
412 public static function page(WP_REST_Request $request)
413 {
414 $boolish = static function ($key) use ($request) {
415 return rest_sanitize_boolean($request->get_param($key));
416 };
417 $places = $request->get_param('places');
418 $args = [
419 'place_id' => sanitize_text_field((string) $request->get_param('place_id')),
420 'min_rating' => (int) $request->get_param('min_rating'),
421 'layout' => sanitize_key((string) $request->get_param('layout')),
422 'show_photo' => $boolish('show_photo'),
423 'show_date' => $boolish('show_date'),
424 'show_stars' => $boolish('show_stars'),
425 'show_images' => $boolish('show_images'),
426 // Pro filter args (ignored by free renderer/filters when inactive).
427 'sort' => sanitize_key((string) $request->get_param('sort')),
428 'keyword' => sanitize_text_field((string) $request->get_param('keyword')),
429 'hide_empty' => $boolish('hide_empty'),
430 'theme' => sanitize_key((string) $request->get_param('theme')),
431 'accent_color' => sanitize_hex_color((string) $request->get_param('accent_color')) ?: '',
432 'places' => is_array($places) ? array_map('sanitize_text_field', $places) : [],
433 ];
434 $offset = max(0, (int) $request->get_param('offset'));
435 $per_page = max(1, min(50, (int) $request->get_param('per_page')));
436
437 $page = GoogleReviewsRenderer::render_page($args, $offset, $per_page);
438 return new WP_REST_Response($page, 200);
439 }
440
441 public static function get_settings()
442 {
443 return new WP_REST_Response([
444 'api_key_configured' => GoogleReviewsRenderer::get_api_key() !== '',
445 'api_key_masked' => self::mask_key(GoogleReviewsRenderer::get_api_key()),
446 'cache_ttl' => GoogleReviewsRenderer::get_cache_ttl(),
447 'api_mode' => GoogleReviewsRenderer::get_api_mode(),
448 'apify_token_configured' => GoogleReviewsRenderer::get_apify_token() !== '',
449 'apify_token_masked' => self::mask_key(GoogleReviewsRenderer::get_apify_token()),
450 'search_provider' => (string) get_option(GoogleReviewsRenderer::OPT_SEARCH_PROVIDER, 'auto'),
451 'search_provider_active' => GoogleReviewsRenderer::get_search_provider(),
452 ], 200);
453 }
454
455 public static function save_settings(WP_REST_Request $request)
456 {
457 $api_key = $request->get_param('api_key');
458 if (is_string($api_key) && $api_key !== '') {
459 // Allow a sentinel "***" to mean "leave the key alone" so the
460 // masked-display flow doesn't accidentally wipe it.
461 if (trim($api_key) !== '***') {
462 $previous = GoogleReviewsRenderer::get_api_key();
463 update_option(GoogleReviewsRenderer::OPT_API_KEY, sanitize_text_field($api_key));
464 // Key changed → invalidate the cached API variant so the next
465 // call re-probes which endpoint family this key is enabled for.
466 if (trim($api_key) !== $previous) {
467 GoogleReviewsRenderer::set_api_mode('auto');
468 }
469 }
470 } elseif ($api_key === '') {
471 delete_option(GoogleReviewsRenderer::OPT_API_KEY);
472 GoogleReviewsRenderer::set_api_mode('auto');
473 }
474
475 $ttl = $request->get_param('cache_ttl');
476 if ($ttl !== null) {
477 $ttl = (int) $ttl;
478 if ($ttl > 0) {
479 update_option(GoogleReviewsRenderer::OPT_CACHE_TTL, $ttl);
480 }
481 }
482
483 // Apify token (Pro "fetch all reviews" provider). "***" sentinel = keep.
484 $apify = $request->get_param('apify_token');
485 if (is_string($apify) && $apify !== '') {
486 if (trim($apify) !== '***') {
487 update_option(GoogleReviewsRenderer::OPT_APIFY_TOKEN, sanitize_text_field($apify));
488 }
489 } elseif ($apify === '') {
490 delete_option(GoogleReviewsRenderer::OPT_APIFY_TOKEN);
491 }
492
493 // Search provider preference: auto | google | apify.
494 $sp = $request->get_param('search_provider');
495 if (is_string($sp) && $sp !== '') {
496 $sp = in_array($sp, ['auto', 'google', 'apify'], true) ? $sp : 'auto';
497 update_option(GoogleReviewsRenderer::OPT_SEARCH_PROVIDER, $sp);
498 }
499
500 return self::get_settings();
501 }
502
503 /**
504 * Live-verify a key/token before it's saved, so the user gets immediate
505 * feedback ("connected" vs "that key didn't work"). Google → a tiny
506 * autocomplete probe; Apify → the token's /users/me endpoint.
507 */
508 public static function verify_key(WP_REST_Request $request)
509 {
510 $provider = sanitize_key((string) $request->get_param('provider'));
511 $key = trim((string) $request->get_param('key'));
512 if ($key === '') {
513 return new WP_REST_Response(['valid' => false, 'message' => __('No key provided.', 'embedpress')], 200);
514 }
515
516 if ($provider === 'apify') {
517 $res = wp_remote_get('https://api.apify.com/v2/users/me?token=' . rawurlencode($key), ['timeout' => 12]);
518 if (is_wp_error($res)) {
519 return new WP_REST_Response(['valid' => false, 'message' => $res->get_error_message()], 200);
520 }
521 $code = (int) wp_remote_retrieve_response_code($res);
522 if ($code === 200) {
523 $body = json_decode(wp_remote_retrieve_body($res), true);
524 $name = $body['data']['username'] ?? '';
525 return new WP_REST_Response(['valid' => true, 'message' => $name ? sprintf(__('Verified (Apify user: %s).', 'embedpress'), $name) : __('Verified.', 'embedpress')], 200);
526 }
527 return new WP_REST_Response(['valid' => false, 'message' => __('Apify rejected this token. Check it in your Apify account → Integrations.', 'embedpress')], 200);
528 }
529
530 // Google: probe Places autocomplete with this exact key (don't touch the
531 // saved option). A successful or zero-results status = the key works.
532 $valid = GoogleReviewsRenderer::verify_google_key($key);
533 if (is_wp_error($valid)) {
534 return new WP_REST_Response(['valid' => false, 'message' => $valid->get_error_message()], 200);
535 }
536 return new WP_REST_Response(['valid' => true, 'message' => __('Verified.', 'embedpress')], 200);
537 }
538
539 public static function clear_cache()
540 {
541 $deleted = GoogleReviewsRenderer::clear_cache();
542 return new WP_REST_Response(['deleted' => $deleted], 200);
543 }
544
545 /**
546 * Connect this install to api.embedpress.com/google-reviews/v1. POSTs
547 * the home_url + fingerprint and stores the Bearer token returned by
548 * the proxy. Idempotent — calling again rotates the token.
549 */
550 public static function managed_connect()
551 {
552 $result = GoogleReviewsManaged::connect();
553 if (empty($result['ok'])) {
554 return new WP_Error(
555 'embedpress_gr_managed_connect_failed',
556 isset($result['message']) ? (string) $result['message'] : __('Connect failed.', 'embedpress'),
557 ['status' => 502]
558 );
559 }
560 return new WP_REST_Response(self::managed_status_payload(), 200);
561 }
562
563 public static function managed_disconnect()
564 {
565 GoogleReviewsManaged::disconnect();
566 return new WP_REST_Response(self::managed_status_payload(), 200);
567 }
568
569 public static function managed_status()
570 {
571 return new WP_REST_Response(self::managed_status_payload(), 200);
572 }
573
574 /**
575 * Shape consumed by the React settings UI: { connected, home_url,
576 * site_id, tier, connected_at }. The token itself is NEVER returned —
577 * only the boolean + binding metadata. Anyone with edit_posts can read
578 * this to know whether to show "Connect" or "Disconnect" affordances.
579 */
580 private static function managed_status_payload(): array
581 {
582 $auth = GoogleReviewsManaged::get_auth();
583 // `would_send` mirrors the payload connect.php will receive — used by
584 // the Connect button on the admin page to show the user exactly what
585 // will leave their server (site URL, admin email, install fingerprint)
586 // BEFORE they click. Honesty over magic-button UX.
587 $would_send = [
588 'site_url' => home_url(),
589 'admin_email' => (string) get_option('admin_email'),
590 ];
591 return [
592 'connected' => $auth !== [],
593 'home_url' => $auth['home_url'] ?? home_url(),
594 'site_id' => $auth['site_id'] ?? '',
595 'tier' => $auth['tier'] ?? '',
596 'connected_at' => isset($auth['connected_at']) ? (int) $auth['connected_at'] : 0,
597 'endpoint' => GoogleReviewsManaged::endpoint(),
598 'would_send' => $would_send,
599 ];
600 }
601
602 /**
603 * Return all saved places from the DB store (the global library), each with
604 * its fetch status so the picker/manager can show "saved / N reviews / last
605 * fetched". Shape stays back-compatible: { places: [...], saved: [...] } —
606 * `saved` mirrors `places` so the existing block picker keeps working.
607 */
608 public static function get_places()
609 {
610 // DRIVE RUNNING JOBS INLINE. The admin UI polls THIS endpoint every 2s
611 // while any place is fetching. Advancing each running job's poll here
612 // (instead of relying on WP-cron, which is unreliable on a quiet admin
613 // page) is what surfaces the worker's live progress — "Looking up your
614 // place…", "Fetched N reviews so far…" — and finalizes the job to done.
615 // Without this, fetch_message stays frozen at the initial placeholder
616 // ("Fetching reviews…") until cron eventually fires. Wrapped per-place
617 // so one bad poll can't break the whole list.
618 foreach (GoogleReviewsStore::all() as $row) {
619 if (($row['fetch_status'] ?? '') !== GoogleReviewsStore::STATUS_RUNNING) {
620 continue;
621 }
622 try {
623 (new GoogleReviewsManaged())->poll_job($row['place_id']);
624 } catch (\Throwable $e) {
625 // Ignore — fall through and return the current store state.
626 }
627 }
628
629 return new WP_REST_Response(self::places_payload(), 200);
630 }
631
632 /**
633 * Per-place fetch status — polled by the block editor for its progress bar.
634 * Returns the live counters without the full reviews payload.
635 */
636 public static function get_status(WP_REST_Request $request)
637 {
638 $place_id = trim((string) $request->get_param('place_id'));
639
640 // DRIVE THE POLL INLINE. Job progress (running → done) is normally
641 // advanced by a WP-cron callback (poll_job), but WP-cron only fires on
642 // site traffic and is unreliable on a quiet admin page — so a place
643 // can sit at "running" in the UI forever even though the proxy
644 // finished seconds ago (the "stuck spinner" bug). Since the admin UI
645 // polls THIS endpoint every few seconds, we advance the job here too:
646 // each status poll checks the proxy and finalizes when done. WP-cron
647 // stays as a backup for when no one is watching the page.
648 if ($place_id !== '') {
649 $pre = GoogleReviewsStore::get($place_id);
650 if ($pre && ($pre['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_RUNNING) {
651 try {
652 (new GoogleReviewsManaged())->poll_job($place_id);
653 } catch (\Throwable $e) {
654 // Never let a poll error break the status read — fall
655 // through and return whatever the store currently has.
656 }
657 }
658 }
659
660 $row = $place_id !== '' ? GoogleReviewsStore::get($place_id) : null;
661 if (!$row) {
662 return new WP_REST_Response([
663 'place_id' => $place_id,
664 'fetch_status' => 'idle',
665 'review_count' => 0,
666 'fetched_so_far' => 0,
667 'fetch_message' => null,
668 'exists' => false,
669 ], 200);
670 }
671 return new WP_REST_Response([
672 'place_id' => $place_id,
673 'fetch_status' => $row['fetch_status'] ?? 'idle',
674 'review_count' => (int) $row['review_count'],
675 'fetched_so_far' => isset($row['fetched_so_far']) ? (int) $row['fetched_so_far'] : 0,
676 'fetch_message' => $row['fetch_message'] ?? null,
677 'last_fetched_at' => $row['last_fetched_at'] ?? null,
678 'exists' => true,
679 ], 200);
680 }
681
682 /**
683 * PUBLIC per-place status poll for the frontend "loading" placeholder.
684 * Reuses get_status() (which also advances a running job, since WP-cron is
685 * unreliable on quiet pages) but returns ONLY non-sensitive job state, and
686 * only for a place that already exists in the store — an unknown place_id
687 * returns idle/0 without touching the network, so the endpoint can't be
688 * abused to enqueue arbitrary fetches.
689 */
690 public static function get_public_status(WP_REST_Request $request)
691 {
692 $place_id = trim((string) $request->get_param('place_id'));
693 if ($place_id === '' || !GoogleReviewsStore::exists($place_id)) {
694 return new WP_REST_Response([
695 'place_id' => $place_id,
696 'fetch_status' => 'idle',
697 'review_count' => 0,
698 'ready' => false,
699 ], 200);
700 }
701
702 // Delegate to the editor handler so the job is advanced identically.
703 $full = self::get_status($request);
704 $data = $full instanceof WP_REST_Response ? $full->get_data() : (array) $full;
705
706 $status = (string) ($data['fetch_status'] ?? 'idle');
707 $count = (int) ($data['review_count'] ?? 0);
708 // "ready" = the poller should stop and reload the block: the job is done
709 // (or failed) and no longer running/queued. The frontend reloads on
710 // done-with-reviews; on failed/done-empty it just stops polling.
711 $ready = !in_array($status, [GoogleReviewsStore::STATUS_RUNNING, GoogleReviewsStore::STATUS_QUEUED], true);
712
713 return new WP_REST_Response([
714 'place_id' => $place_id,
715 'fetch_status' => $status,
716 'review_count' => $count,
717 'ready' => $ready,
718 ], 200);
719 }
720
721 /**
722 * Mutate the global places store. action ∈ {add, remove, refresh}.
723 * Legacy actions {recent, save, unsave} are mapped to add/remove so the
724 * existing block picker continues to work.
725 *
726 * add → lookup-first insert (no re-add/re-fetch if already saved)
727 * remove → delete the place entry
728 * refresh → re-fetch this place's reviews into the store (network)
729 */
730 public static function post_places(WP_REST_Request $request)
731 {
732 $action = sanitize_key((string) $request->get_param('action'));
733 $place_id = sanitize_text_field((string) $request->get_param('place_id'));
734 $place_name = sanitize_text_field((string) $request->get_param('place_name'));
735 // Location description (the search result's secondary_text) — saved so the
736 // saved-place lists can show it without a fetch.
737 $place_address = sanitize_text_field((string) $request->get_param('place_address'));
738
739 if ($place_id === '') {
740 return new WP_Error('embedpress_gr_missing_place', __('place_id is required.', 'embedpress'), ['status' => 400]);
741 }
742
743 // Map legacy picker actions onto the store.
744 if ($action === 'recent' || $action === 'save') {
745 $action = 'add';
746 } elseif ($action === 'unsave') {
747 $action = 'remove';
748 }
749
750 switch ($action) {
751 case 'add':
752 // Free plan supports ONE place in the global library; Pro is
753 // unlimited. Re-adding a place that's already saved is always
754 // fine (idempotent). A NET-NEW place is rejected for free users
755 // once the library already holds a different place — this is the
756 // server-side backstop for the settings-page crown upsell, so the
757 // cap holds regardless of how the add is attempted.
758 if (!Helper::is_pro_active() && !GoogleReviewsStore::get($place_id)) {
759 $existing = GoogleReviewsStore::all();
760 if (is_array($existing) && count($existing) >= 1) {
761 return new WP_Error(
762 'embedpress_gr_pro_required',
763 __('The free plan supports one place. Upgrade to EmbedPress Pro to add unlimited places.', 'embedpress'),
764 ['status' => 403]
765 );
766 }
767 }
768 // Lookup-first: if already saved, this just returns the existing
769 // row — no duplicate, no fetch.
770 $is_new = !GoogleReviewsStore::get($place_id);
771 GoogleReviewsStore::add($place_id, $place_name, $place_address);
772
773 // Seed the search result's real total review count + rating into
774 // the place meta so the row shows the actual numbers right away,
775 // before the background fetch completes. Only seed when missing
776 // (don't clobber a fetched/refreshed value).
777 $seed_total = (int) $request->get_param('total');
778 $seed_rating = (float) $request->get_param('rating');
779 if ($seed_total > 0 || $seed_rating > 0) {
780 GoogleReviewsStore::seed_meta($place_id, [
781 'total' => $seed_total,
782 'rating' => $seed_rating,
783 ]);
784 }
785
786 // ON ADD — WORKER FIRST (hybrid):
787 // 1. Try the EmbedPress worker (start_job → enqueue.php). If
788 // the place was scraped recently it returns a CACHE HIT and
789 // reviews land in the store instantly; otherwise it queues a
790 // Chrome scrape, marks the place "running", and a WP-cron
791 // poller imports the result. Either way NO paid Google
792 // Places Details call is made. This also lets API-missing
793 // places (new/unverified listings, pasted as a Maps URL →
794 // CID/feature handle) get reviews the API can't return.
795 // 2. FALLBACK — only if the worker can't move forward (not
796 // connected / unreachable / refused) do we fetch the first
797 // 5 via Google Places Details so the card still shows
798 // something. This keeps the worker as the primary path per
799 // the "scrape on add, not Places API" contract.
800 if ($is_new) {
801 $job_args = [
802 'fetch_all' => true, // worker pulls up to the ceiling
803 'fetch_max' => 0, // 0 = all up to MAX_REVIEWS_PER_JOB
804 'sort' => 'newest',
805 'places' => [],
806 ];
807 $started = (bool) apply_filters('embedpress/google_reviews/start_fetch_job', false, $place_id, $job_args);
808
809 if (!$started && GoogleReviewsManaged::is_connected()) {
810 // Worker unavailable → Places Details fallback (≤5).
811 $instant = GoogleReviewsManaged::fetch_instant($place_id);
812 if (!empty($instant['ok'])) {
813 $reviews = is_array($instant['reviews'] ?? null) ? $instant['reviews'] : [];
814 $meta = is_array($instant['meta'] ?? null) ? $instant['meta'] : [];
815 if ($reviews || $meta) {
816 GoogleReviewsStore::reset_reviews($place_id);
817 GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true);
818 GoogleReviewsStore::set_job(
819 $place_id,
820 GoogleReviewsStore::STATUS_DONE,
821 ['message' => null, 'run_id' => null]
822 );
823 }
824 }
825 // Don't fail the add if the sub-fetch fails — the place is
826 // saved and the user can hit Refetch.
827 }
828 }
829 break;
830 case 'remove':
831 GoogleReviewsStore::remove($place_id);
832 break;
833 case 'refresh':
834 case 'refetch':
835 // Provider routing for refetch:
836 // - 'google' → synchronous ≤5 API fetch (no background job).
837 // - 'apify' → Pro background batched job via Apify (fetch_all=true).
838 // - 'managed' → hosted scraping proxy at api.embedpress.com,
839 // also a background job (fetch_all=true).
840 // Both background providers hook the start_fetch_job filter;
841 // GoogleReviewsApify wins at priority 10 when a user token is
842 // present, GoogleReviewsManaged falls in at priority 20.
843 $provider = GoogleReviewsRenderer::get_search_provider();
844 $fetch_all = in_array($provider, ['apify', 'managed'], true);
845 $fetch_max = (int) $request->get_param('fetch_max'); // 0 = all
846 $mode = sanitize_key((string) $request->get_param('mode'));
847 $incremental = ($mode === 'quick' || $mode === ''); // default to quick
848 $job_args = [
849 'fetch_all' => $fetch_all,
850 'fetch_max' => max(0, $fetch_max),
851 'sort' => sanitize_key((string) $request->get_param('sort')) ?: 'newest',
852 'places' => [],
853 'incremental' => $incremental,
854 // This is a USER-INITIATED refetch (the Refetch button) — it
855 // must NEVER be served from the proxy's cache. Forces a fresh
856 // scrape regardless of quick/full mode. (Auto/render fetches
857 // omit this flag and still use the cache.)
858 'user_refetch' => true,
859 ];
860
861 // Pro "fetch all" runs as a BACKGROUND batched job (Apify async +
862 // cron) to avoid the 300s sync timeout on large places. Pro hooks
863 // this filter to start the job and returns true; the hosted
864 // scraping proxy (GoogleReviewsManaged) also hooks it at the
865 // lowest priority so it kicks in for users with no Apify token
866 // and no Google key. Falls through to a synchronous ≤5 API fetch
867 // when the active provider is plain Google.
868 $started = $fetch_all
869 ? (bool) apply_filters('embedpress/google_reviews/start_fetch_job', false, $place_id, $job_args)
870 : false;
871
872 if (!$started) {
873 $fetched = GoogleReviewsRenderer::fetch_into_store($place_id, $job_args);
874 if (is_wp_error($fetched)) {
875 return $fetched;
876 }
877 }
878 break;
879 default:
880 return new WP_Error('embedpress_gr_bad_action', __('Unknown action.', 'embedpress'), ['status' => 400]);
881 }
882
883 return new WP_REST_Response(self::places_payload(), 200);
884 }
885
886 /**
887 * Build the places list payload from the DB store.
888 */
889 private static function places_payload(): array
890 {
891 $rows = GoogleReviewsStore::all();
892 $places = array_map(function ($r) {
893 return [
894 'place_id' => $r['place_id'],
895 'place_name' => $r['place_name'] ?: ($r['meta']['name'] ?? ''),
896 // Location description (address) for the saved-place lists.
897 'address' => (string) ($r['meta']['address'] ?? ''),
898 'review_count' => (int) $r['review_count'],
899 'rating' => isset($r['meta']['rating']) ? (float) $r['meta']['rating'] : 0,
900 'total' => isset($r['meta']['total']) ? (int) $r['meta']['total'] : 0,
901 'source' => $r['source'] ?? '',
902 'last_fetched_at' => $r['last_fetched_at'] ?? null,
903 // Background-fetch job state (Pro Apify batched fetch).
904 'fetch_status' => $r['fetch_status'] ?? 'idle',
905 'fetch_message' => $r['fetch_message'] ?? null,
906 'fetched_so_far' => isset($r['fetched_so_far']) ? (int) $r['fetched_so_far'] : 0,
907 ];
908 }, $rows);
909
910 // `saved` kept as an alias of the full list for the legacy block picker;
911 // `recent` left empty (the store has no recency concept — everything is
912 // a saved entry now).
913 return [
914 'places' => $places,
915 'saved' => $places,
916 'recent' => [],
917 ];
918 }
919
920 private static function mask_key(string $key): string
921 {
922 if ($key === '') return '';
923 $len = mb_strlen($key);
924 if ($len <= 6) return str_repeat('*', $len);
925 return mb_substr($key, 0, 4) . str_repeat('*', max(0, $len - 8)) . mb_substr($key, -4);
926 }
927 }
928