app.42a.ai/connect/wordpress. * 2. The app issues a single-use pairing code and redirects back to our * settings page with `42a_code=<32-hex>`. * 3. We exchange the code for a site token (`register`) and store the * site snapshot + verification token. * 4. The verification meta tag is printed on the frontend until 42A has * verified domain ownership. */ class Connect { const CODE_PATTERN = '/^[a-f0-9]{32}$/'; /** * Nonce action for the connect round-trip. A nonce created when the admin * starts the flow is carried through app.42a.ai and verified on return. */ const RETURN_NONCE = 'ael42a_connect_return'; /** * API client. * * @var Api_Client */ private $api; /** * Constructor. * * @param Api_Client $api API client. */ public function __construct( $api ) { $this->api = $api; } /** * Hook everything up. */ public function register_hooks() { add_action( 'admin_init', array( $this, 'maybe_handle_return' ) ); add_action( 'admin_post_ael42a_manual_connect', array( $this, 'handle_manual_connect' ) ); add_action( 'wp_head', array( $this, 'print_verify_meta' ), 1 ); // Daily heartbeat: refreshes the stored snapshot and, if the site was // disconnected on the platform side, wipes the stale local state. add_action( 'ael42a_daily', array( $this, 'daily_sync' ) ); } /** * Daily cron: sync connection state with the platform. */ public function daily_sync() { if ( $this->is_connected() ) { $this->refresh_site_snapshot(); } } /** * The API client (used by the admin page / telemetry). * * @return Api_Client */ public function api() { return $this->api; } /** * Whether this site holds a 42A site token. * * @return bool */ public function is_connected() { return '' !== (string) get_option( 'ael42a_site_token', '' ); } /** * Stored site snapshot (site_id, org, brand, verification status). * * @return array */ public function get_site_snapshot() { $site = get_option( 'ael42a_site', array() ); return is_array( $site ) ? $site : array(); } /** * The app.42a.ai "Connect to 42A" URL for this site. * * A direct link to the app (the views render it as the button target), so * the browser navigates cross-site cleanly - no server-side redirect. It * carries a nonce, both as a top-level `state` param and embedded in * `return_url`, which the app echoes back and we verify on return * (maybe_handle_return). Without a valid nonce the return is ignored, so a * cross-site request cannot complete the pairing. * * @return string */ public function connect_url() { $state = wp_create_nonce( self::RETURN_NONCE ); $return_url = add_query_arg( 'ael42a_state', $state, $this->settings_url() ); return add_query_arg( array( 'site_url' => rawurlencode( $this->site_origin() ), 'return_url' => rawurlencode( $return_url ), 'state' => rawurlencode( $state ), 'v' => rawurlencode( AEL42A_VERSION ), ), $this->api->app_url() . '/connect/wordpress' ); } /** * Handle the cross-site return from app.42a.ai (`42a_code` query arg). * * Restricted to admins on our own settings page, and gated on the nonce * this site embedded in the connect link (see connect_url()) and the app * echoes back. Without a valid nonce the return is ignored, so a * forged/cross-site request cannot pair the site. The pairing code itself * is additionally single-use and origin-bound server-side. */ public function maybe_handle_return() { if ( ! isset( $_GET['42a_code'], $_GET['page'] ) || 'ael42a' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified below. return; } if ( ! current_user_can( 'manage_options' ) ) { return; } $state = ''; if ( isset( $_GET['ael42a_state'] ) ) { $state = sanitize_text_field( wp_unslash( $_GET['ael42a_state'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this value is the nonce, verified on the next line. } elseif ( isset( $_GET['state'] ) ) { $state = sanitize_text_field( wp_unslash( $_GET['state'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this value is the nonce, verified on the next line. } if ( ! wp_verify_nonce( $state, self::RETURN_NONCE ) ) { $this->redirect_with( array( 'connect_error' => rawurlencode( __( 'This connection request has expired or could not be verified. Please click "Connect to 42A" again.', 'ael-42a' ) ) ) ); } $code = strtolower( sanitize_text_field( wp_unslash( $_GET['42a_code'] ) ) ); $this->exchange_and_redirect( $code ); } /** * Manual pairing-code paste (admin-post, nonce-protected). */ public function handle_manual_connect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to connect this site.', 'ael-42a' ) ); } check_admin_referer( 'ael42a_manual_connect' ); $code = isset( $_POST['ael42a_pairing_code'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['ael42a_pairing_code'] ) ) ) : ''; $this->exchange_and_redirect( trim( $code ) ); } /** * Exchange a pairing code and redirect back to the settings page. * * @param string $code Pairing code. */ private function exchange_and_redirect( $code ) { if ( ! preg_match( self::CODE_PATTERN, $code ) ) { $this->redirect_with( array( 'connect_error' => rawurlencode( __( 'Invalid pairing code format.', 'ael-42a' ) ) ) ); } $result = $this->api->register( $code ); if ( is_wp_error( $result ) ) { $this->redirect_with( array( 'connect_error' => rawurlencode( $result->get_error_message() ) ) ); } $token = isset( $result['site_token'] ) ? (string) $result['site_token'] : ''; if ( '' === $token ) { $this->redirect_with( array( 'connect_error' => rawurlencode( __( 'The 42A API did not return a site token.', 'ael-42a' ) ) ) ); } // autoload => 'no' for the token: it is only read on API calls. if ( false === add_option( 'ael42a_site_token', $token, '', 'no' ) ) { update_option( 'ael42a_site_token', $token, 'no' ); } $snapshot = $this->snapshot_from_response( $result ); $snapshot['connected_at'] = gmdate( 'Y-m-d\TH:i:s\Z' ); update_option( 'ael42a_site', $snapshot, false ); if ( ! empty( $result['verification_token'] ) ) { update_option( 'ael42a_verify_token', sanitize_text_field( (string) $result['verification_token'] ), false ); } delete_transient( Cache::VITALS ); $this->redirect_with( array( 'connected' => '1' ) ); } /** * Refresh the stored site snapshot from the API (best-effort). * * @return array|\WP_Error */ public function refresh_site_snapshot() { $site = $this->api->get_site(); if ( is_wp_error( $site ) ) { return $this->maybe_wipe_revoked( $site ); } if ( is_array( $site ) ) { $snapshot = array_merge( $this->get_site_snapshot(), $this->snapshot_from_response( $site ) ); update_option( 'ael42a_site', $snapshot, false ); return $snapshot; } return $this->get_site_snapshot(); } /** * Normalize an API payload (register or GET /site) into the snapshot * shape the admin views read: org{name}, brand{id,name}, * verification{status: verified|pending}. * * The API returns `organization` (register + /site), a * `verification_status`/`status` string of `active` once the domain is * verified, and /site additionally returns `verification.verified`. * * @param array $data Unwrapped API response payload. * @return array Snapshot fields. */ private function snapshot_from_response( $data ) { $org = null; if ( isset( $data['organization'] ) && is_array( $data['organization'] ) ) { $org = $data['organization']; } elseif ( isset( $data['org'] ) && is_array( $data['org'] ) ) { $org = $data['org']; } $brand = isset( $data['brand'] ) && is_array( $data['brand'] ) ? $data['brand'] : null; $verified = false; if ( isset( $data['verification']['verified'] ) ) { $verified = (bool) $data['verification']['verified']; } elseif ( isset( $data['verification_status'] ) ) { $verified = 'active' === $data['verification_status']; } elseif ( isset( $data['status'] ) ) { $verified = 'active' === $data['status']; } $snapshot = array( 'site_id' => isset( $data['site_id'] ) ? $data['site_id'] : '', 'org' => $org, 'brand' => $brand, 'verification' => array( 'status' => $verified ? 'verified' : 'pending' ), ); // GET /site additionally reports who connected the site and when - // shown on the Account tab. Absent on register responses. if ( isset( $data['connected_by'] ) && is_array( $data['connected_by'] ) ) { $snapshot['connected_by'] = array( 'name' => isset( $data['connected_by']['name'] ) ? (string) $data['connected_by']['name'] : '', 'email' => isset( $data['connected_by']['email'] ) ? (string) $data['connected_by']['email'] : '', ); } if ( ! empty( $data['connected_at'] ) && is_string( $data['connected_at'] ) ) { $snapshot['connected_at'] = $data['connected_at']; } return $snapshot; } /** * Connected vitals report, cached for one hour. * * @param bool $force Bypass the cache. * @return array|\WP_Error */ public function get_vitals_cached( $force = false ) { if ( ! $this->is_connected() ) { return new \WP_Error( 'not_connected', __( 'This site is not connected to 42A.', 'ael-42a' ) ); } if ( ! $force ) { $cached = get_transient( Cache::VITALS ); if ( is_array( $cached ) ) { return $cached; } } $vitals = $this->api->get_vitals(); if ( is_wp_error( $vitals ) ) { return $this->maybe_wipe_revoked( $vitals ); } set_transient( Cache::VITALS, $vitals, HOUR_IN_SECONDS ); return $vitals; } /** * Dashboard-mirror visibility stats, cached for one hour. * * @param bool $force Bypass the cache. * @return array|\WP_Error */ public function get_visibility_cached( $force = false ) { if ( ! $this->is_connected() ) { return new \WP_Error( 'not_connected', __( 'This site is not connected to 42A.', 'ael-42a' ) ); } if ( ! $force ) { $cached = get_transient( Cache::VISIBILITY ); if ( is_array( $cached ) ) { return $cached; } } $visibility = $this->api->get_visibility(); if ( is_wp_error( $visibility ) ) { return $this->maybe_wipe_revoked( $visibility ); } set_transient( Cache::VISIBILITY, $visibility, HOUR_IN_SECONDS ); return $visibility; } /** * If the API says our token was revoked (platform-side disconnect), wipe * the stale local connection state so the UI reflects reality, and return * a friendlier error for the current render. * * @param \WP_Error $error API error. * @return \WP_Error The original or replacement error. */ private function maybe_wipe_revoked( $error ) { if ( 'site_disconnected' !== $error->get_error_code() ) { return $error; } $this->wipe_local(); return new \WP_Error( 'site_disconnected', __( 'This site was disconnected from the 42A platform. You can reconnect any time from the Account tab.', 'ael-42a' ) ); } /** * Disconnect: revoke server-side (best effort), then wipe local state. */ public function disconnect() { if ( $this->is_connected() ) { $this->api->disconnect(); // Best effort - ignore failures. } $this->wipe_local(); } /** * Remove all locally stored connection state. */ private function wipe_local() { delete_option( 'ael42a_site_token' ); delete_option( 'ael42a_site' ); delete_option( 'ael42a_verify_token' ); delete_transient( Cache::VITALS ); delete_transient( Cache::VISIBILITY ); } /** * Print the domain-verification meta tag on the frontend. */ public function print_verify_meta() { $token = (string) get_option( 'ael42a_verify_token', '' ); if ( '' === $token ) { return; } printf( '' . "\n", esc_attr( $token ) ); } /** * Site origin (scheme + host [+ port]), as sent to the connect page. * * @return string */ private function site_origin() { $parts = wp_parse_url( home_url( '/' ) ); $origin = $parts['scheme'] . '://' . $parts['host']; if ( ! empty( $parts['port'] ) ) { $origin .= ':' . $parts['port']; } return $origin; } /** * Our settings page URL. * * @return string */ public function settings_url() { return admin_url( 'admin.php?page=ael42a&tab=account' ); } /** * Redirect back to the settings page with extra query args, then exit. * * @param array $args Query args. */ private function redirect_with( $args ) { wp_safe_redirect( add_query_arg( $args, $this->settings_url() ) ); exit; } }