registered[$handle]->src ?? ''; return is_string($src) && preg_match('#^/wp-(admin|includes)/#', $src) === 1; })); } /** * @return void */ public static function handleDecision() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $page = \sanitize_key(\wp_unslash($_GET['page'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ($page !== Metadata::AUTHORIZE_PAGE || !isset($_POST['extendify_mcp_decision'])) { return; } // Without the nonce, any page the administrator visits could post an approval as them. \check_admin_referer(self::NONCE); $decision = \sanitize_key(\wp_unslash($_POST['extendify_mcp_decision'])); $scope = \sanitize_key(\wp_unslash($_POST['extendify_mcp_scope'] ?? '')); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validate() accepts nothing it has not checked. $params = \wp_unslash($_GET); $back = $decision === self::APPROVE ? self::approve($params, $scope) : self::deny($params); if (!$back) { return; } // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- The address is the client's own, checked against its document. \wp_redirect($back); exit; } /** * @return void */ public static function renderPage() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized self::render(\wp_unslash($_GET)); } /** * @param array $params - The authorization request, as sent. * @return void */ public static function render(array $params) { \wp_register_style(self::STYLE, false, [], false); \wp_enqueue_style(self::STYLE); \wp_add_inline_style(self::STYLE, self::styles()); echo '
'; } /** * @param array $params - The authorization request, as sent. * @param string $scope - The access the user chose. * @return string|null - Where to send the client, or null when the request does not stand. */ public static function approve(array $params, $scope) { $request = self::allowed($params); if (!$request) { return null; } $write = $scope === Grants::WRITE && self::writeOffered($request); $code = Tokens::mintCode([ 'userId' => \get_current_user_id(), 'client' => $request['client']['id'], 'label' => $request['client']['name'], 'redirectUri' => $request['redirectUri'], 'codeChallenge' => $request['codeChallenge'], 'grants' => $write ? Grants::names() : [Grants::READ], 'offline' => $request['offline'], 'resource' => Metadata::resource(), ]); return self::back($request, ['code' => $code]); } /** * @param array $params - The authorization request, as sent. * @return string|null - Where to send the client, or null when the request does not stand. */ public static function deny(array $params) { $request = self::allowed($params); return $request ? self::back($request, ['error' => 'access_denied']) : null; } /** * @param array $params - The authorization request, as sent. * @return array|null */ private static function allowed(array $params) { // Without this, any signed-in user could hand an assistant access to the site. if (!Availability::live() || !\current_user_can('manage_options')) { return null; } $request = self::validate($params); return \is_wp_error($request) ? null : $request; } /** * @param array $params - The authorization request, as sent. * @return array|\WP_Error */ private static function validate(array $params) { $sent = function ($key) use ($params) { return is_string($params[$key] ?? null) ? $params[$key] : ''; }; if ($sent('response_type') !== 'code') { return self::refused(\__('The request from the assistant is incomplete or malformed.', 'extendify-local')); } $client = Clients::find($sent('client_id')); if (!$client) { return self::refused(\__( 'The assistant could not be identified. Its identity document is missing or does not name it.', 'extendify-local' )); } $redirect = Clients::redirect($client, $sent('redirect_uri')); if ($redirect === null) { return self::refused(\__( 'The assistant asked to be sent back to an address it did not register.', 'extendify-local' )); } $challenge = $sent('code_challenge'); if ($sent('code_challenge_method') !== 'S256' || !preg_match('/^[A-Za-z0-9._~-]{43,128}$/', $challenge)) { return self::refused(\__('The request is missing the code challenge that protects it.', 'extendify-local')); } if (!Metadata::isResource($sent('resource'))) { return self::refused(\__('The request is for a different site or endpoint.', 'extendify-local')); } return [ 'client' => $client, 'redirectUri' => $redirect, 'codeChallenge' => $challenge, 'state' => $sent('state'), 'grants' => Grants::fromScope($sent('scope')), 'offline' => in_array('offline_access', preg_split('/\s+/', trim($sent('scope'))), true), ]; } /** * @param string $message - What to tell the user. * @return \WP_Error */ private static function refused($message) { return new \WP_Error('extendify_mcp_invalid_request', $message); } /** * @param array $request - The validated request. * @return boolean */ private static function writeOffered(array $request) { return in_array(Grants::WRITE, $request['grants'], true) && Allowed::writable(); } /** * @param array $request - The validated request. * @param array $answer - The parameters to send back. * @return string */ private static function back(array $request, array $answer) { $answer['iss'] = Metadata::issuer(); if ($request['state'] !== '') { $answer['state'] = $request['state']; } // add_query_arg encodes nothing it is handed. return \add_query_arg(array_map('rawurlencode', $answer), $request['redirectUri']); } /** * @param array $params - The authorization request, as sent. * @return void */ private static function renderBody(array $params) { if (!Availability::live()) { self::renderOff(); return; } if (!\current_user_can('manage_options')) { printf('%s
', \esc_html(sprintf( /* translators: %s: the signed-in user's name. */ \__( 'Only an administrator can connect an AI assistant to this site. You are signed in as %s.', 'extendify-local' ), \wp_get_current_user()->display_name ))); return; } $request = self::validate($params); if (\is_wp_error($request)) { printf( '%1$s
%2$s
', \esc_html($request->get_error_message()), \esc_html__('Nothing was sent back to the assistant. Try connecting again from it.', 'extendify-local') ); return; } self::renderConsent($request); } /** * @return void */ private static function renderOff() { if (!Availability::turnedOff()) { echo '' . \esc_html__('This site does not offer connections to AI assistants.', 'extendify-local') . '
'; return; } printf('%s
', \esc_html__( 'Connections are turned off for the whole site, so no assistant can connect right now.', 'extendify-local' )); if (!\current_user_can('manage_options')) { return; } printf( '', \esc_url(\admin_url('options-general.php?page=' . Profile::PAGE)), \esc_html__('Open MCP settings', 'extendify-local') ); } /** * @param array $request - The validated request. * @return void */ private static function renderConsent(array $request) { $user = \wp_get_current_user(); $client = $request['client']; printf('%s
', \wp_kses(sprintf( /* translators: 1: the AI assistant's name. 2: the site's name. 3: the user's name. 4: their username. If the grammar needs a case a name can't take, add a word like "site" or "user" before the name. */ \__('%1$s wants to work on %2$s as %3$s (%4$s).', 'extendify-local'), \esc_html($client['name']), \esc_html(\wp_specialchars_decode(\get_option('blogname'), ENT_QUOTES)), \esc_html($user->display_name), \esc_html($user->user_login) ), ['strong' => []])); echo '', \esc_attr(self::APPROVE), \esc_html__('Approve', 'extendify-local'), \esc_html__('Deny', 'extendify-local') ); } /** * @param array $request - The validated request. * @return void */ private static function renderAccess(array $request) { if (!self::writeOffered($request)) { printf('%s
', \esc_html(Grants::label([Grants::READ]))); return; } echo ''; } /** * Plugin styles are held off this page, so it sets the partner's colours itself. * * @return string */ public static function styles() { $colors = []; foreach (PartnerData::cssVariableMapping() as $variable => $value) { $colors[] = $variable . ': ' . $value; } return \wp_strip_all_tags(':root { ' . implode('; ', $colors) . '; }') . ' ' . Profile::frameStyles('admin_page_' . Metadata::AUTHORIZE_PAGE) . ' .extendify-mcp-consent { max-width: 480px; margin: var(--wpds-dimension-gap-2xl, 24px) auto 0; padding: 24px; background: var(--wpds-color-background-surface-neutral-strong, #fff); border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); border-radius: var(--wpds-border-radius-lg, 8px); } .extendify-mcp-consent > :first-child { margin-top: 0; } .extendify-mcp-authorize .extendify-mcp-access label { display: block; margin: 6px 0; } .extendify-mcp-authorize .extendify-mcp-decide { display: flex; gap: 8px; margin: 16px 0 0; }'; } }