exists() ) { foreach ( (array) $user->roles as $slug ) { // The same charset the mirror applies, for the same reason: a // role the platform would refuse was never created there. if ( 1 === preg_match( '/^[a-zA-Z0-9_-]+$/', (string) $slug ) ) { $roles[] = Roles::PREFIX . $slug; } } } /** * Filters the roles a visitor's search token carries. * * @param string[] $roles Customer role ids. */ $roles = (array) apply_filters( 'sharelyai_visitor_roles', array_values( array_unique( $roles ) ) ); return array_slice( array_values( array_unique( array_map( 'strval', $roles ) ) ), 0, self::MAX_ROLES ); } /** * A bearer for this visitor, from cache when there is one. * * @return array{ok:bool, token:string, roles:array, code:string, message:string} */ public function get(): array { $roles = self::current_roles(); $cached = get_transient( $this->cache_key( $roles ) ); if ( is_string( $cached ) && '' !== $cached ) { return $this->ok( $cached, $roles ); } $minted = $this->mint( $roles ); if ( $minted['ok'] ) { return $minted; } // A role the workspace does not have takes the whole token with it. // Rather than fail the page, fall back to what every entry is tagged // with — the visitor sees public results instead of an error. $fallback = array( Roles::ANONYMOUS ); if ( $roles === $fallback ) { return $minted; } return $this->mint( $fallback ); } /** * The visitor's own space on the platform, for Ask. * * Searching needs only a token. Asking needs a space: the agent hangs its * threads off one, so a conversation belongs to a visitor rather than to * the workspace. `activate-or-retrieve-user-space` is idempotent — the same * `customerIdString` comes back to the same space. * * The id is deliberately thin. For a signed-in user it is their WordPress * id, which this site already knows; for everyone else it is a random * value in a cookie that means nothing anywhere else. No name, no address, * no email ever leaves here. * * @return array{ok:bool, token:string, space_id:string, code:string, message:string} */ public function space(): array { $visitor = self::visitor_id(); $roles = self::current_roles(); $key = self::TRANSIENT . '_space_' . md5( $this->connection->workspace_id() . '|' . $visitor . '|' . implode( ',', $roles ) ); $cached = get_transient( $key ); if ( is_array( $cached ) && ! empty( $cached['token'] ) && ! empty( $cached['space_id'] ) ) { return array( 'ok' => true, 'token' => (string) $cached['token'], 'space_id' => (string) $cached['space_id'], 'code' => '', 'message' => '', ); } $bearer = $this->get(); if ( ! $bearer['ok'] ) { return array( 'ok' => false, 'token' => '', 'space_id' => '', 'code' => $bearer['code'], 'message' => $bearer['message'], ); } $result = ( new Client( $this->connection ) )->request( 'PUT', '/activate-or-retrieve-user-space', array( 'headers' => array( 'Authorization' => 'Bearer ' . $bearer['token'] ), 'body' => array( 'workspaceId' => $this->connection->workspace_id(), 'customerIdString' => $visitor, ), 'timeout' => 15, ) ); $data = is_array( $result['data'] ) ? $result['data'] : array(); $token = (string) ( $data['token'] ?? '' ); $space = (string) ( $data['spaceId'] ?? $data['id'] ?? '' ); if ( ! $result['ok'] || '' === $token || '' === $space ) { return array( 'ok' => false, 'token' => '', 'space_id' => '', 'code' => '' !== $result['code'] ? $result['code'] : 'NO_SPACE', 'message' => '' !== $result['message'] ? $result['message'] : __( 'Sharely did not return a space for this visitor.', 'sharelyai' ), ); } set_transient( $key, array( 'token' => $token, 'space_id' => $space, ), self::TTL ); return array( 'ok' => true, 'token' => $token, 'space_id' => $space, 'code' => '', 'message' => '', ); } /** * The name this visitor is known by on the platform, and nowhere else. * * A signed-in user is their WordPress id. Everyone else gets a random * value in a cookie, so their conversation survives a page load without * anybody learning who they are. The cookie carries no personal data, which * is why it needs no consent banner to exist. */ public static function visitor_id(): string { $user = wp_get_current_user(); if ( $user instanceof \WP_User && $user->exists() ) { return 'wp-user-' . $user->ID; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- sanitized on the next line and matched against a fixed charset below. $existing = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : ''; if ( 1 === preg_match( '/^[a-f0-9]{32}$/', $existing ) ) { return 'wp-anon-' . $existing; } $fresh = bin2hex( random_bytes( 16 ) ); if ( ! headers_sent() ) { setcookie( self::COOKIE, $fresh, array( 'expires' => time() + MONTH_IN_SECONDS, 'path' => defined( 'COOKIEPATH' ) ? COOKIEPATH : '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); $_COOKIE[ self::COOKIE ] = $fresh; } return 'wp-anon-' . $fresh; } /** * One mint, cached on success. * * @param string[] $roles Roles to ask for. * * @return array{ok:bool, token:string, roles:array, code:string, message:string} */ private function mint( array $roles ): array { $result = ( new Client( $this->connection ) )->mint_bearer( $roles ); $token = is_array( $result['data'] ) ? (string) ( $result['data']['token'] ?? '' ) : ''; if ( ! $result['ok'] || '' === $token ) { return array( 'ok' => false, 'token' => '', 'roles' => $roles, 'code' => '' !== $result['code'] ? $result['code'] : 'NO_TOKEN', 'message' => '' !== $result['message'] ? $result['message'] : __( 'Sharely did not return a token for this visitor.', 'sharelyai' ), ); } set_transient( $this->cache_key( $roles ), $token, self::TTL ); return $this->ok( $token, $roles ); } /** * A success. * * @param string $token The bearer. * @param string[] $roles What it carries. * * @return array{ok:bool, token:string, roles:array, code:string, message:string} */ private function ok( string $token, array $roles ): array { return array( 'ok' => true, 'token' => $token, 'roles' => $roles, 'code' => '', 'message' => '', ); } /** * Keyed by workspace and role set — never by user. * * Two visitors holding the same roles may share a token; that is what the * token says about them. Keying by user would multiply the mints by the * size of the membership for no gain in privacy. * * @param string[] $roles The role set. */ private function cache_key( array $roles ): string { sort( $roles ); return self::TRANSIENT . '_' . md5( $this->connection->workspace_id() . '|' . implode( ',', $roles ) ); } /** * Drops every cached visitor token. Used when the workspace changes. */ public static function forget_all(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the key carries a hash, so there is no name to delete. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( '_transient_' . self::TRANSIENT ) . '%', $wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT ) . '%' ) ); } }