store = $store; $this->api = $api; } /** * Register the single allowlisted administration endpoint. * * @return void */ public function register() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Register the local WordPress REST route. * * @return void */ public function register_routes() { register_rest_route( 'akintu/v1', '/manage', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_request' ), 'permission_callback' => static function () { return current_user_can( 'manage_options' ); }, ) ); } /** * Dispatch one validated management operation. * * @param WP_REST_Request $request WordPress REST request. * @return WP_REST_Response|WP_Error */ public function handle_request( WP_REST_Request $request ) { $connection = $this->store->get(); if ( 'connected' !== ( $connection['status'] ?? '' ) || empty( $connection['connection_id'] ) || empty( $connection['management_token'] ) ) { return new WP_Error( 'akintu_connection_required', __( 'Connect this site to Akintu before using administration tools.', 'akintu' ), array( 'status' => 409 ) ); } $operation = sanitize_key( (string) $request->get_param( 'operation' ) ); $params = $request->get_param( 'params' ); $body = $request->get_param( 'body' ); $params = is_array( $params ) ? $params : array(); $body = is_array( $body ) ? $this->sanitize_body( $body ) : array(); $target = $this->resolve_operation( $operation, $params, $body ); if ( is_wp_error( $target ) ) { return $target; } $result = $this->api->management_request( $target['method'], $target['path'], $connection['connection_id'], $connection['management_token'], $target['body'], $target['raw'] ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502; return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) ); } if ( 'rotate_verification' === $operation && isset( $result['verificationToken'] ) && is_string( $result['verificationToken'] ) ) { $connection['verification_token'] = sanitize_text_field( $result['verificationToken'] ); $this->store->save( $connection ); $result = array( 'updated' => true ); } return rest_ensure_response( $result ); } /** * Resolve an operation to one immutable API target. * * @param string $operation Operation identifier. * @param array $params Validated path/query input. * @param array $body Sanitized JSON body. * @return array|WP_Error */ private function resolve_operation( $operation, array $params, array $body ) { $target = array( 'method' => 'GET', 'path' => '', 'body' => array(), 'raw' => false, ); switch ( $operation ) { case 'overview': $target['path'] = '/overview' . $this->query_string( $params, array( 'from', 'to' ) ); break; case 'conversations': $target['path'] = '/conversations' . $this->query_string( $params, array( 'from', 'to', 'q', 'outcome', 'topic', 'knowledgeGap', 'sentiment', 'limit' ) ); break; case 'conversation': $target['path'] = $this->uuid_path( $params, '/conversations/' ); break; case 'export_conversations': $target['path'] = '/conversations/export.csv' . $this->query_string( $params, array( 'from', 'to', 'q', 'outcome', 'topic', 'knowledgeGap', 'sentiment', 'limit' ) ); $target['raw'] = true; break; case 'analyze_conversations': $target['method'] = 'POST'; $target['path'] = '/conversations/analyze'; $target['body'] = $body; break; case 'documents': $target['path'] = '/documents'; break; case 'add_text': $target['method'] = 'POST'; $target['path'] = '/documents/text'; $target['body'] = $body; break; case 'delete_document': $target['method'] = 'DELETE'; $target['path'] = $this->uuid_path( $params, '/documents/' ); break; case 'upload_grant': $target['method'] = 'POST'; $target['path'] = '/document-upload-grants'; $target['body'] = $body; break; case 'site': $target['path'] = '/site'; break; case 'verify_site': $target['method'] = 'POST'; $target['path'] = '/site/verify'; break; case 'rotate_verification': $target['method'] = 'POST'; $target['path'] = '/site/verification-token/rotate'; break; case 'site_pages': $target['path'] = '/site/pages'; break; case 'rescan_page': case 'exclude_page': case 'restore_page': $action = str_replace( '_page', '', $operation ); $target['method'] = 'POST'; $target['path'] = $this->uuid_path( $params, '/site/pages/', '/' . $action ); break; case 'site_scans': $target['path'] = '/site/scans'; break; case 'start_scan': $target['method'] = 'POST'; $target['path'] = '/site/scans'; $target['body'] = $body; break; case 'cancel_scan': $target['method'] = 'POST'; $target['path'] = $this->uuid_path( $params, '/site/scans/', '/cancel' ); break; case 'settings': $target['path'] = '/settings'; break; case 'update_settings': $target['method'] = 'PATCH'; $target['path'] = '/settings'; $target['body'] = $body; break; case 'features': $target['path'] = '/features'; break; case 'update_features': $target['method'] = 'PATCH'; $target['path'] = '/features'; $target['body'] = $body; break; default: return new WP_Error( 'akintu_operation_invalid', __( 'This Akintu administration operation is not available.', 'akintu' ), array( 'status' => 400 ) ); } if ( is_wp_error( $target['path'] ) ) { return $target['path']; } return $target; } /** * Build an API path around one validated UUID. * * @param array $params Request parameters. * @param string $prefix Path prefix. * @param string $suffix Path suffix. * @return string|WP_Error */ private function uuid_path( array $params, $prefix, $suffix = '' ) { $identifier = $this->require_uuid( $params, 'id' ); return is_wp_error( $identifier ) ? $identifier : $prefix . $identifier . $suffix; } /** * Require and normalize one UUID parameter. * * @param array $params Request parameters. * @param string $key Parameter name. * @return string|WP_Error */ private function require_uuid( array $params, $key ) { $value = isset( $params[ $key ] ) ? (string) $params[ $key ] : ''; if ( 1 !== preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value ) ) { return new WP_Error( 'akintu_identifier_invalid', __( 'The requested Akintu resource identifier is invalid.', 'akintu' ), array( 'status' => 400 ) ); } return strtolower( $value ); } /** * Build a query string using only explicitly allowed scalar keys. * * @param array $params Candidate query values. * @param string[] $allowed Allowed keys. * @return string */ private function query_string( array $params, array $allowed ) { $query = array(); foreach ( $allowed as $key ) { if ( isset( $params[ $key ] ) && is_scalar( $params[ $key ] ) ) { $query[ $key ] = sanitize_text_field( (string) $params[ $key ] ); } } return empty( $query ) ? '' : '?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ); } /** * Recursively sanitize JSON-compatible settings without removing line breaks. * * @param array $body Input body. * @return array */ private function sanitize_body( array $body ) { $sanitized = array(); foreach ( $body as $key => $value ) { $safe_key = is_int( $key ) ? $key : preg_replace( '/[^A-Za-z0-9_]/', '', (string) $key ); if ( is_array( $value ) ) { $sanitized[ $safe_key ] = $this->sanitize_body( $value ); } elseif ( is_string( $value ) ) { $sanitized[ $safe_key ] = sanitize_textarea_field( $value ); } elseif ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { $sanitized[ $safe_key ] = $value; } } return $sanitized; } }