capabilities ) as $cap ) { if ( ! isset( $wanted[ $cap ] ) ) { $role->remove_cap( $cap ); } } foreach ( $wanted as $cap => $grant ) { $role->add_cap( $cap, $grant ); } } update_option( self::OPT_ROLE_VER, self::ROLE_VERSION, true ); } /** Capabilities of the dedicated user: write content and media for the SEO agent, nothing else. */ public static function role_capabilities(): array { return [ 'read' => true, 'edit_posts' => true, 'edit_others_posts' => true, 'edit_published_posts' => true, 'publish_posts' => true, 'edit_pages' => true, 'edit_others_pages' => true, 'edit_published_pages' => true, 'publish_pages' => true, 'upload_files' => true, self::CAPABILITY => true, ]; } // ── Settings card ─────────────────────────────────────────────────────────── /** Rendered at the top of the settings page, outside its options form. */ public function render_card(): void { $status = get_option( self::OPT_STATUS, [] ); $status = is_array( $status ) ? $status : []; $notice = isset( $_GET['agentsia_connect'] ) ? sanitize_key( wp_unslash( $_GET['agentsia_connect'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $message = get_transient( 'agentsia_connect_message_' . get_current_user_id() ); $blocking = $this->blocking_reason(); // Site relié AVANT le bouton, par une clé collée à la main : il fonctionne, on propose seulement de passer au bouton. $manual = empty( $status['connected'] ) && ! empty( Plugin::get_settings()['api_key'] ); ?>

guard( 'agentsia_connect_start' ); $blocking = $this->blocking_reason(); if ( $blocking !== null ) { $this->back( 'error', $blocking ); } $state = bin2hex( random_bytes( 24 ) ); set_transient( self::STATE_PREFIX . get_current_user_id(), $state, 10 * MINUTE_IN_SECONDS ); $settings = Plugin::get_settings(); $target = add_query_arg( [ 'site' => rawurlencode( home_url( '/' ) ), 'rest' => rawurlencode( get_rest_url() ), 'state' => $state, 'return' => rawurlencode( admin_url( 'admin-post.php?action=agentsia_connect_return' ) ), 'version' => rawurlencode( AGENTSIA_AGENTS_VERSION ), ], untrailingslashit( (string) $settings['api_base'] ) . '/connect/wordpress' ); wp_redirect( $target ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- external by design: the Agentsia app. exit; } // ── Step 2: back from Agentsia with a one-time code ──────────────────────── public function handle_return(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Accès refusé.', 'agentsia-agents' ), 403 ); } $code = isset( $_GET['agentsia_code'] ) ? sanitize_text_field( wp_unslash( $_GET['agentsia_code'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the `state` below is the CSRF check. $state = isset( $_GET['agentsia_state'] ) ? sanitize_text_field( wp_unslash( $_GET['agentsia_state'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $key = self::STATE_PREFIX . get_current_user_id(); $saved = get_transient( $key ); delete_transient( $key ); // single use, whatever happens next if ( ! is_string( $saved ) || $saved === '' || $state === '' || ! hash_equals( $saved, $state ) || ! preg_match( '/^[A-Za-z0-9_-]{20,128}$/', $code ) ) { $this->back( 'error', __( 'Connexion expirée ou invalide : recommencez depuis le bouton « Connecter à Agentsia ».', 'agentsia-agents' ) ); } $user = $this->dedicated_user(); if ( is_wp_error( $user ) ) { $this->back( 'error', $user->get_error_message() ); } if ( ! wp_is_application_passwords_available_for_user( $user ) ) { $this->back( 'error', __( 'Les mots de passe d\'application sont bloqués pour cet utilisateur (extension de sécurité ?).', 'agentsia-agents' ) ); } $this->revoke_passwords( $user->ID ); $created = \WP_Application_Passwords::create_new_application_password( $user->ID, [ 'name' => self::APP_NAME ] ); if ( is_wp_error( $created ) ) { $this->back( 'error', $created->get_error_message() ); } [ $password ] = $created; $site_uuid = (string) get_option( self::OPT_SITE_UUID, '' ); if ( $site_uuid === '' ) { $site_uuid = wp_generate_uuid4(); update_option( self::OPT_SITE_UUID, $site_uuid, false ); } $nonce = bin2hex( random_bytes( 24 ) ); update_option( self::OPT_NONCE, $nonce, false ); $settings = Plugin::get_settings(); $response = wp_remote_post( untrailingslashit( (string) $settings['api_base'] ) . '/api/public/wp/connect', [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ], 'body' => wp_json_encode( [ 'code' => $code, 'state' => $state, 'site_url' => home_url( '/' ), 'rest_url' => get_rest_url(), 'site_uuid' => $site_uuid, 'username' => $user->user_login, 'app_password' => $password, 'plugin_nonce' => $nonce, 'plugin_version' => AGENTSIA_AGENTS_VERSION, // Articles published by the agent are signed by the admin who connected the site, not by « Agentsia ». 'default_author_id' => get_current_user_id(), ] ), ] ); delete_option( self::OPT_NONCE ); // the proof of possession happened during that request $status_code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( is_wp_error( $response ) || $status_code !== 200 || ! is_array( $body ) || empty( $body['api_key'] ) ) { $this->revoke_passwords( $user->ID ); // nothing must stay usable after a failed connection $reason = is_wp_error( $response ) ? $response->get_error_message() : ( is_array( $body ) && ! empty( $body['error'] ) ? (string) $body['error'] : sprintf( 'HTTP %d', $status_code ) ); /* translators: %s: reason. */ $this->back( 'error', sprintf( __( 'Agentsia n\'a pas pu finaliser la connexion : %s', 'agentsia-agents' ), $reason ) ); } $api_key = (string) $body['api_key']; if ( ! preg_match( '/^agsk_[a-f0-9]{48}$/', $api_key ) ) { $this->revoke_passwords( $user->ID ); $this->back( 'error', __( 'Réponse inattendue d\'Agentsia.', 'agentsia-agents' ) ); } $current = Plugin::get_settings(); $current['api_key'] = $api_key; update_option( Plugin::OPTION_KEY, $current ); update_option( self::OPT_STATUS, [ 'connected' => true, 'workspace' => isset( $body['workspace'] ) ? sanitize_text_field( (string) $body['workspace'] ) : '', 'connected_at' => time(), 'user_login' => $user->user_login, ], false ); $previous = isset( $body['previous_username'] ) && is_string( $body['previous_username'] ) ? sanitize_user( $body['previous_username'] ) : ''; $this->back( 'ok', $previous !== '' /* translators: %s: WordPress user login. */ ? sprintf( __( 'Site connecté à Agentsia. Le mot de passe d\'application du compte « %1$s » n\'est plus utilisé par Agentsia : vous pouvez le révoquer (Utilisateurs › %1$s › Mots de passe d\'application).', 'agentsia-agents' ), $previous ) : __( 'Site connecté à Agentsia.', 'agentsia-agents' ) ); } // ── Disconnect ────────────────────────────────────────────────────────────── public function handle_disconnect(): void { $this->guard( 'agentsia_disconnect' ); $settings = Plugin::get_settings(); if ( ! empty( $settings['api_key'] ) ) { // Best effort: Agentsia marks the integration disconnected and revokes the key. wp_remote_post( untrailingslashit( (string) $settings['api_base'] ) . '/api/public/wp/disconnect', [ 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . $settings['api_key'], 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'site_uuid' => (string) get_option( self::OPT_SITE_UUID, '' ) ] ), ] ); } $user_id = (int) get_option( self::OPT_USER, 0 ); if ( $user_id > 0 ) { $this->revoke_passwords( $user_id ); } $settings['api_key'] = ''; update_option( Plugin::OPTION_KEY, $settings ); delete_option( self::OPT_STATUS ); $this->back( 'ok', __( 'Site déconnecté d\'Agentsia : son accès est révoqué.', 'agentsia-agents' ) ); } // ── Proof of possession ───────────────────────────────────────────────────── public function register_routes(): void { register_rest_route( Seo_Meta::REST_NAMESPACE, '/environment', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_environment' ], 'permission_callback' => static function () { return current_user_can( self::CAPABILITY ) || current_user_can( 'manage_options' ); }, ] ); register_rest_route( Seo_Meta::REST_NAMESPACE, '/authors', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_authors' ], 'permission_callback' => static function () { return current_user_can( self::CAPABILITY ) || current_user_can( 'list_users' ); }, ] ); } /** * Authors the agent may sign articles with, for the author choices of the Agentsia app: users who can * publish posts, plus the « Signataire Agentsia » and contributors (they sign what the agent publishes). * Only id, name and slug: the dedicated user does not get `list_users` (the full directory, e-mails * included), which WordPress requires for `/wp/v2/users?context=edit`. */ public function handle_authors(): \WP_REST_Response { $base = [ 'number' => 200, 'orderby' => 'display_name', 'exclude' => array_filter( [ (int) get_option( self::OPT_USER, 0 ) ] ), 'fields' => [ 'ID', 'display_name', 'user_nicename' ], ]; // `capability` exists since WordPress 5.9; before, the default publishing roles. $publishers = version_compare( get_bloginfo( 'version' ), '5.9', '>=' ) ? $base + [ 'capability' => [ 'publish_posts' ] ] : $base + [ 'role__in' => [ 'administrator', 'editor', 'author' ] ]; $signers = $base + [ 'role__in' => [ Site_Members::SIGNATAIRE_ROLE, 'contributor' ] ]; $authors = []; foreach ( array_merge( get_users( $publishers ), get_users( $signers ) ) as $u ) { $authors[ (int) $u->ID ] = [ 'id' => (int) $u->ID, 'name' => (string) $u->display_name, 'slug' => (string) $u->user_nicename ]; } uasort( $authors, static function ( $a, $b ) { return strcasecmp( $a['name'], $b['name'] ); } ); return new \WP_REST_Response( array_values( $authors ), 200 ); } public function handle_environment(): \WP_REST_Response { return new \WP_REST_Response( [ 'site_uuid' => (string) get_option( self::OPT_SITE_UUID, '' ), // Only present while a connection is being finalised: Agentsia checks it matches what it received. 'nonce' => (string) get_option( self::OPT_NONCE, '' ), 'plugin_version' => AGENTSIA_AGENTS_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'user' => wp_get_current_user()->user_login, ], 200 ); } // ── Helpers ───────────────────────────────────────────────────────────────── /** @return \WP_User|\WP_Error */ private function dedicated_user() { if ( ! get_role( self::ROLE ) ) { add_role( self::ROLE, __( 'Agentsia (agent SEO)', 'agentsia-agents' ), self::role_capabilities() ); update_option( self::OPT_ROLE_VER, self::ROLE_VERSION, true ); } $user_id = (int) get_option( self::OPT_USER, 0 ); $user = $user_id > 0 ? get_userdata( $user_id ) : false; if ( $user instanceof \WP_User ) { // Keep the role exactly as declared (an admin could have widened it). $user->set_role( self::ROLE ); return $user; } $login = 'agentsia-' . strtolower( wp_generate_password( 6, false, false ) ); $user_id = wp_insert_user( [ 'user_login' => $login, 'user_pass' => wp_generate_password( 64, true, true ), // never given to anyone: no interactive login 'display_name' => 'Agentsia', 'nickname' => 'Agentsia', 'role' => self::ROLE, 'description' => __( 'Utilisateur technique de l\'agent SEO Agentsia (contenus et médias seulement). Son accès est révoqué quand vous déconnectez le site dans Réglages › Agentsia Agents.', 'agentsia-agents' ), ] ); if ( is_wp_error( $user_id ) ) { return $user_id; } update_option( self::OPT_USER, (int) $user_id, false ); return get_userdata( (int) $user_id ); } private function revoke_passwords( int $user_id ): void { foreach ( \WP_Application_Passwords::get_user_application_passwords( $user_id ) as $item ) { if ( ( $item['name'] ?? '' ) === self::APP_NAME ) { \WP_Application_Passwords::delete_application_password( $user_id, $item['uuid'] ); } } } private function guard( string $action ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Accès refusé.', 'agentsia-agents' ), 403 ); } check_admin_referer( $action ); } /** Back to the settings page with a message (kept per user, shown once). */ private function back( string $kind, string $message ): void { set_transient( 'agentsia_connect_message_' . get_current_user_id(), $message, MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( 'agentsia_connect', $kind === 'ok' ? 'ok' : 'error', admin_url( 'options-general.php?page=agentsia-agents' ) ) ); exit; } }