knowledge = $knowledge ?? new Knowledge( $connection ); } /** * The Sharely ids for a set of this connector's role ids. * * All or nothing, on purpose. Tagging an item with some of the roles that * should be on it is not a smaller version of the right answer — it is * content hidden from people entitled to read it, silently, with nothing * on any screen to say so. A failure here is reported against the post * instead, where an administrator can see it and act. * * @param string[] $customer_role_ids e.g. `wp:editor`. * * @return array{ok:bool, ids:array, code:string, message:string} */ public function ids_for( array $customer_role_ids ): array { if ( array() === $customer_role_ids ) { return array( 'ok' => false, 'ids' => array(), 'code' => 'NO_ROLES', 'message' => __( 'No role on this site may read this entry, so there is nobody to send it for.', 'sharelyai' ), ); } $ids = array(); foreach ( $customer_role_ids as $customer_role_id ) { $resolved = $this->resolve( (string) $customer_role_id ); if ( ! $resolved['ok'] ) { return array( 'ok' => false, 'ids' => array(), 'code' => $resolved['code'], 'message' => $resolved['message'], ); } $ids[] = $resolved['id']; } return array( 'ok' => true, 'ids' => array_values( array_unique( $ids ) ), 'code' => '', 'message' => '', ); } /** * The Sharely role ids a public page should be tagged with. * * @return array{ok:bool, ids:array, code:string, message:string} */ /** * Mirrors this site's roles into the workspace. * * The Drupal module does this as the first step of a sync — roles, then a * batch per page, then the tidy-up — and for the same reason: a visitor's * token names the roles they hold, and the platform resolves them all or * nothing. A role that exists in WordPress and not in the workspace makes * every mint for a visitor holding it fail with `400 Role not found`, and * search stops for that person entirely. * * Nothing here tags content. Public entries carry `wp:anonymous` and that * remains true; this makes the rest of the roles *exist*, which is what the * tokens need, and is the half of the access work that can be done before * private content is handled. * * Best effort by design: one role that cannot be created does not stop a * sync of public content, which only needs the anonymous role. The count is * recorded so the status screen can say what is mirrored and what is not. * * @return array{ok:bool, mirrored:int, total:int, skipped:int, message:string} */ public function mirror(): array { $names = wp_roles()->get_names(); $targets = array( self::ANONYMOUS => __( 'Anonymous visitors', 'sharelyai' ) ); $skipped = 0; foreach ( $names as $slug => $label ) { if ( 1 !== preg_match( self::SLUG, (string) $slug ) ) { ++$skipped; continue; } $targets[ self::PREFIX . $slug ] = translate_user_role( (string) $label ); } $mirrored = 0; $message = ''; foreach ( $targets as $customer_role_id => $label ) { $resolved = $this->resolve( (string) $customer_role_id, (string) $label ); if ( $resolved['ok'] ) { ++$mirrored; continue; } if ( '' === $message ) { $message = $resolved['message']; } } $state = array( 'ok' => count( $targets ) === $mirrored, 'mirrored' => $mirrored, 'total' => count( $targets ), 'skipped' => $skipped, 'message' => $message, ); update_option( self::MIRROR_OPTION, $state + array( 'time' => time() ), false ); return $state; } /** * What the last mirror did, or nulls if there has not been one. * * @return array{mirrored:int, total:int, skipped:int, time:int, message:string} */ public static function mirror_state(): array { $state = get_option( self::MIRROR_OPTION ); $state = is_array( $state ) ? $state : array(); return array( 'mirrored' => (int) ( $state['mirrored'] ?? 0 ), 'total' => (int) ( $state['total'] ?? 0 ), 'skipped' => (int) ( $state['skipped'] ?? 0 ), 'time' => (int) ( $state['time'] ?? 0 ), 'message' => (string) ( $state['message'] ?? '' ), ); } /** * The Sharely role ids a public page should be tagged with. * * @return array{ok:bool, ids:array, code:string, message:string} */ public function public_role_ids(): array { $resolved = $this->resolve( self::ANONYMOUS ); if ( ! $resolved['ok'] ) { return array( 'ok' => false, 'ids' => array(), 'code' => $resolved['code'], 'message' => $resolved['message'], ); } return array( 'ok' => true, 'ids' => array( $resolved['id'] ), 'code' => '', 'message' => '', ); } /** * The Sharely id for one of our role ids, creating the role if it is missing. * * Provisioning creates `wp:anonymous`, so the usual path is a lookup. It is * created here as well because a workspace connected before provisioning * knew about WordPress will not have it, and because an untagged page on an * RBAC workspace is invisible rather than public — failing to create it * silently would hide the whole site from search. * * @param string $customer_role_id e.g. `wp:anonymous`. * @param string $name What to call it if it has to be created. * * @return array{ok:bool, id:string, code:string, message:string} */ public function resolve( string $customer_role_id, string $name = '' ): array { $cached = get_transient( $this->cache_key() ); $cached = is_array( $cached ) ? $cached : array(); if ( isset( $cached[ $customer_role_id ] ) && '' !== $cached[ $customer_role_id ] ) { return array( 'ok' => true, 'id' => (string) $cached[ $customer_role_id ], 'code' => '', 'message' => '', ); } $found = $this->knowledge->find_role( $customer_role_id ); if ( $found['ok'] && '' !== $found['id'] ) { return $this->remember( $cached, $customer_role_id, $found['id'] ); } if ( '' === $name ) { $name = self::ANONYMOUS === $customer_role_id ? __( 'Anonymous visitors', 'sharelyai' ) : $customer_role_id; } $created = $this->knowledge->create_role( $name, $customer_role_id ); if ( $created['ok'] && '' !== $created['id'] ) { return $this->remember( $cached, $customer_role_id, $created['id'] ); } // Creating can lose a race with another request, or with provisioning: // look once more before calling it a failure. $again = $this->knowledge->find_role( $customer_role_id ); if ( $again['ok'] && '' !== $again['id'] ) { return $this->remember( $cached, $customer_role_id, $again['id'] ); } return array( 'ok' => false, 'id' => '', 'code' => '' !== $created['code'] ? $created['code'] : $found['code'], 'message' => sprintf( /* translators: 1: the role id, 2: what the platform said. */ __( 'Sharely has no role %1$s and it could not be created, so pages would be invisible to every visitor. %2$s', 'sharelyai' ), $customer_role_id, '' !== $created['message'] ? $created['message'] : $found['message'] ), ); } /** * Caches a resolved id and returns it. * * @param array $cached What was cached. * @param string $customer_role_id Our id. * @param string $sharely_id Sharely's id. * * @return array{ok:bool, id:string, code:string, message:string} */ private function remember( array $cached, string $customer_role_id, string $sharely_id ): array { $cached[ $customer_role_id ] = $sharely_id; set_transient( $this->cache_key(), $cached, DAY_IN_SECONDS ); return array( 'ok' => true, 'id' => $sharely_id, 'code' => '', 'message' => '', ); } /** * Forgets the resolved ids. Used when a workspace changes under us. */ public function forget(): void { delete_option( self::MIRROR_OPTION ); delete_transient( $this->cache_key() ); // The unnamespaced key of earlier builds. Harmless when absent, and the // one that actually caused a site to send another workspace's role id. delete_transient( self::TRANSIENT ); } /** * The cache key for the workspace this connection points at. */ private function cache_key(): string { return self::TRANSIENT . '_' . md5( $this->connection->workspace_id() ); } }