guard( 'sharelyai_connect' ); $verifier = self::base64_url( random_bytes( 48 ) ); $challenge = self::base64_url( hash( 'sha256', $verifier, true ) ); $state = bin2hex( random_bytes( 16 ) ); $redirect = self::redirect_uri(); set_transient( self::PENDING . get_current_user_id(), array( 'state' => $state, 'verifier' => $verifier, 'redirect_uri' => $redirect, ), self::PENDING_TTL ); $consent = $this->connection->app_base() . '/connect/wordpress?' . http_build_query( array( 'response_type' => 'code', 'client_id' => self::CLIENT_ID, 'redirect_uri' => $redirect, 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', 'scope' => self::SCOPE, 'site' => home_url(), 'site_name' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), ) ); // Not wp_safe_redirect: this redirect deliberately leaves the site, for // the host the administrator configured as Sharely. wp_safe_redirect() // would send them back to wp-admin instead, which is the bug, not the fix. wp_redirect( $consent ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } /** * Completes the flow. */ public function callback(): void { $this->guard( 'sharelyai_callback', 'GET' ); $key = self::PENDING . get_current_user_id(); $pending = get_transient( $key ); delete_transient( $key ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the state below is this request's nonce; guard() has already checked capability. $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; // The state first, always. Until it matches, every string in this // request belongs to whoever sent it — `error_description` included, // which would otherwise be a sentence of their choosing shown to an // administrator. if ( ! is_array( $pending ) || ! hash_equals( (string) $pending['state'], $state ) ) { $this->finish( 'error', __( 'That connection request did not match this site. Please click Connect to Sharely again.', 'sharelyai' ) ); } // Declining carries an error and no code. Checked after the state and // before the code, or a cancelled approval reads as a failure to retry. if ( isset( $_GET['error'] ) ) { $described = isset( $_GET['error_description'] ) ? sanitize_text_field( wp_unslash( $_GET['error_description'] ) ) : sanitize_text_field( wp_unslash( $_GET['error'] ) ); $this->finish( 'error', sprintf( /* translators: %s: the reason Sharely gave. */ __( 'Sharely did not complete the connection: %s', 'sharelyai' ), $described ) ); } $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( '' === $code ) { $this->finish( 'error', __( 'That connection request did not match this site. Please click Connect to Sharely again.', 'sharelyai' ) ); } $client = new Client( $this->connection ); $token = $client->app_request( 'POST', '/api/oauth/token', array( 'form' => array( 'grant_type' => 'authorization_code', 'code' => $code, 'code_verifier' => (string) $pending['verifier'], 'redirect_uri' => (string) $pending['redirect_uri'], 'client_id' => self::CLIENT_ID, ), ) ); if ( ! $token['ok'] ) { $this->finish( 'error', $this->explain_token( $token ) ); } $access = is_array( $token['data'] ) ? (string) ( $token['data']['access_token'] ?? '' ) : ''; if ( '' === $access ) { $this->finish( 'error', __( 'Sharely approved the connection but returned no token. Please try again.', 'sharelyai' ) ); } $provision = $client->app_request( 'POST', '/api/connect/provision', array( 'headers' => array( 'Authorization' => 'Bearer ' . $access ), 'body' => array( 'siteUrl' => home_url(), 'siteName' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), 'source' => 'wordpress', ), 'timeout' => 30, ) ); if ( ! $provision['ok'] ) { $this->finish( 'error', $this->explain_provision( $provision ) ); } $data = is_array( $provision['data'] ) ? $provision['data'] : array(); // All three or nothing: a half-stored connection shows as connected and // fails on the first sync, which is far harder to work out than this. foreach ( array( 'workspaceId', 'organizationId', 'apiKey' ) as $required ) { if ( '' === (string) ( $data[ $required ] ?? '' ) ) { $this->finish( 'error', __( 'Sharely did not return a complete workspace. Nothing was saved; please try again.', 'sharelyai' ) ); } } $this->store( $data ); $workspace = (string) ( '' !== (string) ( $data['workspaceName'] ?? '' ) ? $data['workspaceName'] : $data['workspaceId'] ); $queued = $this->start_first_sync(); if ( $queued > 0 ) { $this->finish( 'success', sprintf( /* translators: 1: the workspace name, 2: number of entries. */ _n( 'Connected to %1$s. Sending %2$s entry now — this page shows the progress.', 'Connected to %1$s. Sending %2$s entries now — this page shows the progress.', $queued, 'sharelyai' ), $workspace, number_format_i18n( $queued ) ) ); } $this->finish( 'success', sprintf( /* translators: %s: the workspace name. */ __( 'Connected to %s. There is nothing in scope to send yet — check the post types under Settings.', 'sharelyai' ), $workspace ) ); } /** * Writes a successful provision to the option. * * @param array $data The provision response. */ private function store( array $data ): void { $values = array( 'workspace_id' => (string) $data['workspaceId'], 'workspace_name' => (string) ( $data['workspaceName'] ?? '' ), 'organization_id' => (string) $data['organizationId'], 'api_key' => (string) $data['apiKey'], 'rbac_enabled' => (bool) ( $data['rbacEnabled'] ?? true ), 'anonymous_role_id' => (string) ( $data['anonymousRoleId'] ?? 'wp:anonymous' ), 'site_base_url' => home_url(), 'connected_at' => time(), ); // Sharely may be deployed somewhere other than the defaults; it says so // here, and it is the only one that knows. foreach ( array( 'api_base_url' => 'apiBaseUrl', 'app_base_url' => 'appBaseUrl', ) as $setting => $field ) { if ( '' !== (string) ( $data[ $field ] ?? '' ) ) { $values[ $setting ] = (string) $data[ $field ]; } } // First connection: write the scope the defaults already imply, so the // settings screen shows the real answer and a post type added later does // not silently join the scope of a site that had chosen its own. if ( ! is_array( $this->connection->get( 'post_types' ) ) ) { $values['post_types'] = Connection::public_post_types(); } // Anything cached against the previous workspace goes now. Reconnecting // to a different workspace while holding the old one's role ids is how a // sync ends up reporting `roleIds[0] must be a valid UUID` on every // entry, with nothing on the screen to say why. ( new Credentials( $this->connection ) )->forget(); ( new Roles( $this->connection ) )->forget(); $this->connection->save( $values ); ( new Credentials( $this->connection ) )->forget(); ( new Roles( $this->connection ) )->forget(); } /** * Starts the first sync, so connecting is the only thing to do. * * An administrator who has just approved the connection has said what they * want; asking them to find a Sync button before anything happens is asking * them to say it twice. The site stays unconnected on every failure path, so * this only runs once a workspace and a key are actually stored. * * Failure is deliberately quiet. The connection succeeded, which is what the * message after this is about; a sync that could not start is the status * screen's business, and it says so there. * * @return int How many entries were queued. */ private function start_first_sync(): int { $started = ( new Sync( $this->connection ) )->start_full(); return $started['ok'] ? (int) $started['total'] : 0; } /** * Tells Sharely this site has disconnected itself. * * Without it the site stops syncing and goes on looking connected in the * Knowledge app — a live site in the dashboard that is not live, which is * the one state nobody can act on. The administrator is in WordPress, not * in Sharely, so there is no user session to authenticate with: the call * carries the workspace API key, which is what the platform itself takes. * * It does not delete the key. Deleting one is user-authenticated on the * backend and this request has no user; the dashboard offers the revoke * that finishes the job, and says so. * * Best effort, deliberately. Disconnecting must work when Sharely is * unreachable — a site that cannot cut itself loose because the other end * is down is worse than a stale row in a dashboard. The caller carries on * either way; this only reports so the message can mention it. * * @return bool Whether Sharely recorded it. */ public function announce_disconnect(): bool { if ( ! $this->connection->is_connected() ) { return false; } $result = ( new Client( $this->connection ) )->app_request( 'POST', '/api/connect/disconnect', array( 'headers' => array( 'x-api-key' => $this->connection->api_key() ), 'body' => array( 'site' => home_url(), 'workspaceId' => $this->connection->workspace_id(), ), 'timeout' => 10, ) ); return (bool) $result['ok']; } /** * Why the token exchange failed, in words an administrator can act on. * * @param array $result A failed app_request() result. */ private function explain_token( array $result ): string { if ( 'TRANSPORT' === $result['code'] ) { return __( 'Could not reach Sharely from this server. Check outbound HTTPS and any firewall, then try again.', 'sharelyai' ); } if ( 'invalid_grant' === $result['code'] ) { // The approval is good for about a minute and for one exchange. // "Approve again" is actionable; "something went wrong" is not. return __( 'That approval has already been used or has expired. Click Connect to Sharely and approve again.', 'sharelyai' ); } return __( 'Sharely would not complete the connection. Click Connect to Sharely and approve again.', 'sharelyai' ); } /** * Why provisioning failed. * * @param array $result A failed app_request() result. */ private function explain_provision( array $result ): string { if ( 'TRANSPORT' === $result['code'] ) { return __( 'Could not reach Sharely from this server. Check outbound HTTPS and any firewall, then try again.', 'sharelyai' ); } if ( 404 === (int) $result['status'] ) { return __( 'Sharely has no record of approving this site. Click Connect to Sharely and approve again.', 'sharelyai' ); } return __( 'Sharely could not finish setting up the workspace. Nothing was saved; please try again.', 'sharelyai' ); } /** * Only an administrator, and only a request this site started. * * @param string $action Nonce action. * @param string $method POST for the form, GET for the callback. */ private function guard( string $action, string $method = 'POST' ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'sharelyai' ), 403 ); } if ( 'POST' === $method ) { check_admin_referer( $action ); } // The callback carries no nonce of ours: it comes from Sharely, and // `state` is what proves this site started it. Checked in callback(). } /** * Back to the settings screen with something to read. * * @param string $type success|error. * @param string $message What to say. */ private function finish( string $type, string $message ): void { set_transient( 'sharelyai_notice', array( 'type' => $type, 'message' => $message, ), 60 ); wp_safe_redirect( Settings::page_url() ); exit; } /** * Base64url, as PKCE wants it: no padding, URL-safe alphabet. * * @param string $raw Bytes. */ private static function base64_url( string $raw ): string { return rtrim( strtr( base64_encode( $raw ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- PKCE is specified in base64url, not an obfuscation. } }