@@ -47,7 +47,12 @@ public function register_hooks(): void {
4747 'roi_insights_tracking_save ' => 'handle_tracking_save ' ,
4848 'roi_insights_settings_load ' => 'handle_settings_load ' ,
4949 'roi_insights_settings_save ' => 'handle_settings_save ' ,
50- 'roi_insights_onboarding_submit ' => 'handle_onboarding_submit ' ,
50+ 'roi_insights_onboarding_submit ' => 'handle_onboarding_submit ' ,
51+ 'roi_insights_onboarding_complete ' => 'handle_onboarding_complete ' ,
52+ 'roi_insights_onboarding_scan ' => 'handle_onboarding_scan ' ,
53+ 'roi_insights_onboarding_state ' => 'handle_onboarding_state ' ,
54+ 'roi_insights_integrations_get ' => 'handle_integrations_get ' ,
55+ 'roi_insights_integrations_disconnect ' => 'handle_integrations_disconnect ' ,
5156 );
5257
5358 foreach ( $ actions as $ action => $ method ) {
@@ -67,7 +72,7 @@ private function verify_request(): void {
6772 /** Decode the JSON-encoded 'data' field sent by api.post(). */
6873 private function get_body (): array {
6974 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- already verified in verify_request().
70- $ raw = isset ( $ _REQUEST ['data ' ] ) ? wp_unslash ( $ _REQUEST ['data ' ] ) : '{} ' ;
75+ $ raw = isset ( $ _POST ['data ' ] ) ? wp_unslash ( $ _POST ['data ' ] ) : '{} ' ;
7176 $ decoded = json_decode ( $ raw , true );
7277 return is_array ( $ decoded ) ? $ decoded : array ();
7378 }
@@ -83,8 +88,8 @@ public function handle_license_status(): void {
8388 /** Return the OAuth popup URL — no backend call needed. */
8489 public function handle_license_sso (): void {
8590 $ this ->verify_request ();
86- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
87- $ src = isset ( $ _REQUEST ['src ' ] ) ? sanitize_text_field ( wp_unslash ( $ _REQUEST [ ' src ' ] ) ) : ' default ' ;
91+ $ body = $ this -> get_body ();
92+ $ src = sanitize_text_field ( $ body ['src ' ] ?? ' default ' ) ;
8893 $ provider = ( 'default ' === $ src ) ? 'google ' : $ src ;
8994 $ domain = wp_parse_url ( home_url (), PHP_URL_HOST );
9095 $ url = self ::BACKEND . '/api/roi/plugin/auth/ ' . rawurlencode ( $ provider ) . '/redirect?domain= ' . rawurlencode ( $ domain );
@@ -123,11 +128,16 @@ public function handle_license_notify(): void {
123128 $ this ->send_backend_result ( $ result );
124129 }
125130
126- /** Poll magic-link verification status. */
131+ /**
132+ * Poll magic-link verification status.
133+ * The poll_token arrives via POST body (not URL) from the frontend to avoid
134+ * browser history / CDN log exposure. The server-to-server call to the backend
135+ * uses a query param since that's the endpoint's contract.
136+ */
127137 public function handle_license_pending (): void {
128138 $ this ->verify_request ();
129- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
130- $ poll_token = isset ( $ _REQUEST ['poll_token ' ] ) ? sanitize_text_field ( wp_unslash ( $ _REQUEST [ ' poll_token ' ] ) ) : '' ;
139+ $ body = $ this -> get_body ();
140+ $ poll_token = sanitize_text_field ( $ body ['poll_token ' ] ?? '' ) ;
131141 $ result = $ this ->backend_get ( '/api/roi/plugin/auth/magic-link/status?poll_token= ' . rawurlencode ( $ poll_token ) );
132142 $ this ->send_backend_result ( $ result );
133143 }
@@ -136,7 +146,13 @@ public function handle_license_pending(): void {
136146 public function handle_license_validate (): void {
137147 $ this ->verify_request ();
138148 $ this ->license ->clear_cache ();
139- wp_send_json_success ( $ this ->license ->get_license_data ( true ) );
149+ try {
150+ wp_send_json_success ( $ this ->license ->get_license_data ( true ) );
151+ } catch ( \Throwable $ e ) {
152+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions
153+ error_log ( 'roi-insights: license validation failed: ' . $ e ->getMessage () . ' in ' . $ e ->getFile () . ': ' . $ e ->getLine () );
154+ wp_send_json_error ( array ( 'error ' => 'Validation failed. Please try again or contact support if the issue persists. ' ), 503 );
155+ }
140156 }
141157
142158 // ─── Tracking Handlers ──────────────────────────────────────────────────────
@@ -176,9 +192,15 @@ public function handle_settings_save(): void {
176192 wp_send_json_success ( array ( 'ok ' => true ) );
177193 }
178194
179- // ─── Onboarding Handler ─ ───────────────────────────────────────────────────
195+ // ─── Onboarding Handlers ───────────────────────────────────────────────────
180196
181- /** Submit onboarding wizard data (business context, objectives, channels, budget). */
197+ /**
198+ * Submit onboarding wizard data (business context, objectives, channels, budget)
199+ * and signal completion to trigger the dashboard backfill.
200+ *
201+ * Portal endpoints require the sessionToken (looker_api_key), not the plugin
202+ * license key. See portal_auth_headers().
203+ */
182204 public function handle_onboarding_submit (): void {
183205 $ this ->verify_request ();
184206 $ body = $ this ->get_body ();
@@ -214,17 +236,105 @@ public function handle_onboarding_submit(): void {
214236 ),
215237 );
216238
217- foreach ( $ steps as $ step ) {
218- $ result = $ this ->backend_post ( $ step ['path ' ], $ step ['body ' ] );
239+ foreach ( $ steps as $ idx => $ step ) {
240+ $ result = $ this ->backend_portal_post ( $ step ['path ' ], $ step ['body ' ] );
219241 if ( $ result ['status ' ] < 200 || $ result ['status ' ] >= 300 ) {
242+ $ result ['body ' ] = is_array ( $ result ['body ' ] ) ? $ result ['body ' ] : array ();
243+ $ result ['body ' ]['failed_step ' ] = basename ( $ step ['path ' ] );
244+ $ result ['body ' ]['failed_step_index ' ] = $ idx ;
220245 $ this ->send_backend_result ( $ result );
221- return ; // wp_send_json_error exits, but return for clarity.
246+ return ;
222247 }
223248 }
224249
250+ // Step 5: Signal onboarding completion — triggers the dashboard backfill.
251+ // The /complete endpoint is idempotent on the backend (sets state to
252+ // ONBOARDING_COMPLETE; duplicate calls are no-ops).
253+ $ complete = $ this ->backend_portal_post ( '/api/portal/onboarding/complete ' , new \stdClass () );
254+ if ( $ complete ['status ' ] < 200 || $ complete ['status ' ] >= 300 ) {
255+ $ complete ['body ' ] = is_array ( $ complete ['body ' ] ) ? $ complete ['body ' ] : array ();
256+ $ complete ['body ' ]['failed_step ' ] = 'complete ' ;
257+ $ complete ['body ' ]['failed_step_index ' ] = count ( $ steps );
258+ $ this ->send_backend_result ( $ complete );
259+ return ;
260+ }
261+
225262 wp_send_json_success ( array ( 'ok ' => true ) );
226263 }
227264
265+ /** Trigger a website scan for onboarding Step 1. */
266+ public function handle_onboarding_scan (): void {
267+ $ this ->verify_request ();
268+
269+ // Rate limit: 1 scan per minute per user.
270+ $ throttle_key = 'roi_scan_ ' . get_current_blog_id () . '_ ' . get_current_user_id ();
271+ if ( get_transient ( $ throttle_key ) ) {
272+ wp_send_json_error ( array ( 'message ' => 'Please wait before scanning again. ' ), 429 );
273+ }
274+ set_transient ( $ throttle_key , 1 , 60 );
275+
276+ $ body = $ this ->get_body ();
277+ $ url = esc_url_raw ( $ body ['url ' ] ?? '' );
278+
279+ // SSRF guard: only allow scanning the site's own domain over HTTPS.
280+ // Normalize IDN/punycode to ASCII for consistent comparison, and reject
281+ // URLs containing userinfo (user:pass@host) which could bypass host checks.
282+ $ parsed = wp_parse_url ( $ url );
283+ if ( ! is_array ( $ parsed ) ) {
284+ wp_send_json_error ( array ( 'message ' => 'Invalid scan URL. ' ), 400 );
285+ }
286+ if ( ! empty ( $ parsed ['user ' ] ) || ! empty ( $ parsed ['pass ' ] ) ) {
287+ wp_send_json_error ( array ( 'message ' => 'Invalid scan URL. ' ), 400 );
288+ }
289+
290+ $ scan_scheme = $ parsed ['scheme ' ] ?? '' ;
291+ $ scan_host = strtolower ( $ parsed ['host ' ] ?? '' );
292+ $ site_host = strtolower ( wp_parse_url ( home_url (), PHP_URL_HOST ) );
293+
294+ // Normalize IDN domains to ASCII (punycode) for safe comparison.
295+ if ( function_exists ( 'idn_to_ascii ' ) ) {
296+ $ scan_host = idn_to_ascii ( $ scan_host , IDNA_DEFAULT , INTL_IDNA_VARIANT_UTS46 ) ?: $ scan_host ;
297+ $ site_host = idn_to_ascii ( $ site_host , IDNA_DEFAULT , INTL_IDNA_VARIANT_UTS46 ) ?: $ site_host ;
298+ }
299+
300+ if ( 'https ' !== $ scan_scheme || $ scan_host !== $ site_host ) {
301+ wp_send_json_error ( array ( 'message ' => 'Scan URL must match the site domain over HTTPS. ' ), 400 );
302+ }
303+
304+ $ result = $ this ->backend_portal_post ( '/api/portal/onboarding/website-scan ' , array ( 'url ' => $ url ) );
305+ $ this ->send_backend_result ( $ result );
306+ }
307+
308+ /** Poll onboarding state (includes scan status/results). */
309+ public function handle_onboarding_state (): void {
310+ $ this ->verify_request ();
311+ $ result = $ this ->backend_portal_get ( '/api/portal/onboarding/state ' );
312+ $ this ->send_backend_result ( $ result );
313+ }
314+
315+ /** Standalone completion endpoint — allows retry if the /complete call fails independently. */
316+ public function handle_onboarding_complete (): void {
317+ $ this ->verify_request ();
318+ $ result = $ this ->backend_portal_post ( '/api/portal/onboarding/complete ' , new \stdClass () );
319+ $ this ->send_backend_result ( $ result );
320+ }
321+
322+ // ─── Integrations Handlers ─────────────────────────────────────────────────
323+
324+ /** Fetch connected service statuses for the Activation tab. */
325+ public function handle_integrations_get (): void {
326+ $ this ->verify_request ();
327+ $ result = $ this ->backend_portal_get ( '/api/portal/integrations ' );
328+ $ this ->send_backend_result ( $ result );
329+ }
330+
331+ /** Disconnect the Google account (keeps license key valid). */
332+ public function handle_integrations_disconnect (): void {
333+ $ this ->verify_request ();
334+ $ result = $ this ->backend_portal_delete ( '/api/portal/integrations/google ' );
335+ $ this ->send_backend_result ( $ result );
336+ }
337+
228338 // ─── Response Helpers ───────────────────────────────────────────────────────
229339
230340 /**
@@ -271,20 +381,93 @@ private function backend_post( string $path, array $body ): array {
271381 return $ this ->parse_response ( $ response );
272382 }
273383
384+ /**
385+ * Auth headers for plugin-specific endpoints (/api/roi/plugin/*).
386+ * Uses the qdsh_… license key stored in wp_options.
387+ *
388+ * Only sends X-License-Key — the single header plugin endpoints expect.
389+ * Portal endpoints use portal_auth_headers() with a Bearer token instead.
390+ */
274391 private function auth_headers (): array {
275392 $ key = $ this ->settings ->get_license_key ();
276393 if ( ! $ key ) {
277394 return array ();
278395 }
279- // Send the key in all formats the backend may expect.
280- // Plugin endpoints use X-License-Key; portal endpoints use Authorization or X-API-Key.
281396 return array (
282- 'X-License-Key ' => $ key ,
283- 'X-API-Key ' => $ key ,
284- 'Authorization ' => 'Bearer ' . $ key ,
397+ 'Accept ' => 'application/json ' ,
398+ 'X-License-Key ' => $ key ,
285399 );
286400 }
287401
402+ /**
403+ * Auth headers for portal endpoints (/api/portal/*).
404+ *
405+ * Portal endpoints authenticate via the looker_api_key (sessionToken),
406+ * NOT the qdsh_… plugin license key. The sessionToken is returned by the
407+ * /api/roi/plugin/validate endpoint and cached in the license transient.
408+ */
409+ private function portal_auth_headers (): array {
410+ $ data = $ this ->license ->get_license_data ();
411+ $ token = $ data ['sessionToken ' ] ?? '' ;
412+ if ( empty ( $ token ) ) {
413+ // No sessionToken available — return Accept only so the request fails
414+ // with a clear 401 rather than sending a mismatched X-License-Key
415+ // that portal endpoints won't recognize.
416+ return array ( 'Accept ' => 'application/json ' );
417+ }
418+ return array (
419+ 'Accept ' => 'application/json ' ,
420+ 'Authorization ' => 'Bearer ' . $ token ,
421+ );
422+ }
423+
424+ // ─── Portal Backend Proxy Helpers ───────────────────────────────────────────
425+
426+ /**
427+ * @return array{status:int,body:mixed}
428+ */
429+ private function backend_portal_get ( string $ path ): array {
430+ $ response = wp_remote_get (
431+ self ::BACKEND . $ path ,
432+ array (
433+ 'timeout ' => 10 ,
434+ 'headers ' => $ this ->portal_auth_headers (),
435+ )
436+ );
437+ return $ this ->parse_response ( $ response );
438+ }
439+
440+ /**
441+ * @param object|array $body Use stdClass for empty {} payloads.
442+ * @return array{status:int,body:mixed}
443+ */
444+ private function backend_portal_post ( string $ path , $ body ): array {
445+ $ response = wp_remote_post (
446+ self ::BACKEND . $ path ,
447+ array (
448+ 'timeout ' => 10 ,
449+ 'headers ' => array_merge ( $ this ->portal_auth_headers (), array ( 'Content-Type ' => 'application/json ' ) ),
450+ 'body ' => wp_json_encode ( $ body ),
451+ )
452+ );
453+ return $ this ->parse_response ( $ response );
454+ }
455+
456+ /**
457+ * @return array{status:int,body:mixed}
458+ */
459+ private function backend_portal_delete ( string $ path ): array {
460+ $ response = wp_remote_request (
461+ self ::BACKEND . $ path ,
462+ array (
463+ 'method ' => 'DELETE ' ,
464+ 'timeout ' => 10 ,
465+ 'headers ' => $ this ->portal_auth_headers (),
466+ )
467+ );
468+ return $ this ->parse_response ( $ response );
469+ }
470+
288471 /**
289472 * @param WP_Error|array $response
290473 * @return array{status:int,body:mixed}
0 commit comments