diff --git a/inc/admin-pages/class-dashboard-admin-page.php b/inc/admin-pages/class-dashboard-admin-page.php index bc7e38746..6679b6190 100644 --- a/inc/admin-pages/class-dashboard-admin-page.php +++ b/inc/admin-pages/class-dashboard-admin-page.php @@ -125,7 +125,7 @@ public function hooks(): void { * * @since 2.0.0 * - * @param WP_Ultimo\Admin_Pages\Base_Admin_Page $page The page object. + * @param \WP_Ultimo\Admin_Pages\Base_Admin_Page $page The page object. * @return void */ public function render_filter($page): void { diff --git a/inc/admin-pages/class-list-admin-page.php b/inc/admin-pages/class-list-admin-page.php index e8ade8527..16d4e116b 100644 --- a/inc/admin-pages/class-list-admin-page.php +++ b/inc/admin-pages/class-list-admin-page.php @@ -47,7 +47,7 @@ abstract class List_Admin_Page extends Base_Admin_Page { * Holds the WP_List_Table instance to be used on the list * * @since 1.8.2 - * @var WP_List_Table + * @var \WP_List_Table */ protected $table; @@ -217,7 +217,7 @@ public function save_screen_option($value, $option, $other_value) { * Dumb function. Child classes need to implement this to set the table that Ultimate Multisite will use * * @since 1.8.2 - * @return WP_List_Table + * @return \WP_List_Table */ public function get_table() { diff --git a/inc/admin-pages/class-site-edit-admin-page.php b/inc/admin-pages/class-site-edit-admin-page.php index 0d88afd8c..fae2697c0 100644 --- a/inc/admin-pages/class-site-edit-admin-page.php +++ b/inc/admin-pages/class-site-edit-admin-page.php @@ -646,12 +646,12 @@ public function action_links() { 'icon' => 'wu-cog', ], [ - 'url' => get_site_url($this->get_object()->get_id()), + 'url' => $this->get_object()->get_active_site_url(), 'label' => __('Visit Site', 'ultimate-multisite'), 'icon' => 'wu-link', ], [ - 'url' => get_admin_url($this->get_object()->get_id()), + 'url' => wu_get_admin_url($this->get_object()->get_id()), 'label' => __('Dashboard', 'ultimate-multisite'), 'icon' => 'dashboard', ], diff --git a/inc/admin-pages/class-site-list-admin-page.php b/inc/admin-pages/class-site-list-admin-page.php index 4069315e6..2eb7d60a4 100644 --- a/inc/admin-pages/class-site-list-admin-page.php +++ b/inc/admin-pages/class-site-list-admin-page.php @@ -231,12 +231,10 @@ public function handle_publish_pending_site_modal(): void { * Handles the add/edit of line items. * * @since 2.0.0 - * @return mixed + * @return void */ public function handle_add_new_site_modal() { - global $current_site; - $domain_type = wu_request('tab', is_subdomain_install() ? 'sub-domain' : 'sub-directory'); if ('domain' === $domain_type) { @@ -263,13 +261,13 @@ public function handle_add_new_site_modal() { $site = wu_create_site($atts); if (is_wp_error($site)) { - return wp_send_json_error($site); + wp_send_json_error($site); } - if ($site->get_blog_id() === false) { + if (! $site->get_blog_id()) { $error = new \WP_Error('error', __('Something wrong happened.', 'ultimate-multisite')); - return wp_send_json_error($error); + wp_send_json_error($error); } $redirect = current_user_can('wu_edit_sites') ? 'wp-ultimo-edit-site' : 'wp-ultimo-sites'; diff --git a/inc/api/class-register-endpoint.php b/inc/apis/class-register-endpoint.php similarity index 99% rename from inc/api/class-register-endpoint.php rename to inc/apis/class-register-endpoint.php index 085169142..fbd342eec 100644 --- a/inc/api/class-register-endpoint.php +++ b/inc/apis/class-register-endpoint.php @@ -566,7 +566,7 @@ public function maybe_create_customer($p) { * * @param array $p The request parameters. * @param \WP_Ultimo\Models\Membership $membership The membership created. - * @return array|\WP_Ultimo\Models\Site\|\WP_Error + * @return array|\WP_Error */ public function maybe_create_site($p, $membership) { diff --git a/inc/api/schemas/broadcast-create.php b/inc/apis/schemas/broadcast-create.php similarity index 100% rename from inc/api/schemas/broadcast-create.php rename to inc/apis/schemas/broadcast-create.php diff --git a/inc/api/schemas/broadcast-update.php b/inc/apis/schemas/broadcast-update.php similarity index 100% rename from inc/api/schemas/broadcast-update.php rename to inc/apis/schemas/broadcast-update.php diff --git a/inc/api/schemas/checkout-form-create.php b/inc/apis/schemas/checkout-form-create.php similarity index 100% rename from inc/api/schemas/checkout-form-create.php rename to inc/apis/schemas/checkout-form-create.php diff --git a/inc/api/schemas/checkout-form-update.php b/inc/apis/schemas/checkout-form-update.php similarity index 100% rename from inc/api/schemas/checkout-form-update.php rename to inc/apis/schemas/checkout-form-update.php diff --git a/inc/api/schemas/customer-create.php b/inc/apis/schemas/customer-create.php similarity index 100% rename from inc/api/schemas/customer-create.php rename to inc/apis/schemas/customer-create.php diff --git a/inc/api/schemas/customer-update.php b/inc/apis/schemas/customer-update.php similarity index 100% rename from inc/api/schemas/customer-update.php rename to inc/apis/schemas/customer-update.php diff --git a/inc/api/schemas/discount-code-create.php b/inc/apis/schemas/discount-code-create.php similarity index 100% rename from inc/api/schemas/discount-code-create.php rename to inc/apis/schemas/discount-code-create.php diff --git a/inc/api/schemas/discount-code-update.php b/inc/apis/schemas/discount-code-update.php similarity index 100% rename from inc/api/schemas/discount-code-update.php rename to inc/apis/schemas/discount-code-update.php diff --git a/inc/api/schemas/domain-create.php b/inc/apis/schemas/domain-create.php similarity index 100% rename from inc/api/schemas/domain-create.php rename to inc/apis/schemas/domain-create.php diff --git a/inc/api/schemas/domain-update.php b/inc/apis/schemas/domain-update.php similarity index 100% rename from inc/api/schemas/domain-update.php rename to inc/apis/schemas/domain-update.php diff --git a/inc/api/schemas/email-create.php b/inc/apis/schemas/email-create.php similarity index 100% rename from inc/api/schemas/email-create.php rename to inc/apis/schemas/email-create.php diff --git a/inc/api/schemas/email-update.php b/inc/apis/schemas/email-update.php similarity index 100% rename from inc/api/schemas/email-update.php rename to inc/apis/schemas/email-update.php diff --git a/inc/api/schemas/event-create.php b/inc/apis/schemas/event-create.php similarity index 100% rename from inc/api/schemas/event-create.php rename to inc/apis/schemas/event-create.php diff --git a/inc/api/schemas/event-update.php b/inc/apis/schemas/event-update.php similarity index 100% rename from inc/api/schemas/event-update.php rename to inc/apis/schemas/event-update.php diff --git a/inc/api/schemas/membership-create.php b/inc/apis/schemas/membership-create.php similarity index 100% rename from inc/api/schemas/membership-create.php rename to inc/apis/schemas/membership-create.php diff --git a/inc/api/schemas/membership-update.php b/inc/apis/schemas/membership-update.php similarity index 100% rename from inc/api/schemas/membership-update.php rename to inc/apis/schemas/membership-update.php diff --git a/inc/api/schemas/payment-create.php b/inc/apis/schemas/payment-create.php similarity index 100% rename from inc/api/schemas/payment-create.php rename to inc/apis/schemas/payment-create.php diff --git a/inc/api/schemas/payment-update.php b/inc/apis/schemas/payment-update.php similarity index 100% rename from inc/api/schemas/payment-update.php rename to inc/apis/schemas/payment-update.php diff --git a/inc/api/schemas/product-create.php b/inc/apis/schemas/product-create.php similarity index 100% rename from inc/api/schemas/product-create.php rename to inc/apis/schemas/product-create.php diff --git a/inc/api/schemas/product-update.php b/inc/apis/schemas/product-update.php similarity index 100% rename from inc/api/schemas/product-update.php rename to inc/apis/schemas/product-update.php diff --git a/inc/api/schemas/site-create.php b/inc/apis/schemas/site-create.php similarity index 100% rename from inc/api/schemas/site-create.php rename to inc/apis/schemas/site-create.php diff --git a/inc/api/schemas/site-update.php b/inc/apis/schemas/site-update.php similarity index 100% rename from inc/api/schemas/site-update.php rename to inc/apis/schemas/site-update.php diff --git a/inc/api/schemas/webhook-create.php b/inc/apis/schemas/webhook-create.php similarity index 100% rename from inc/api/schemas/webhook-create.php rename to inc/apis/schemas/webhook-create.php diff --git a/inc/api/schemas/webhook-update.php b/inc/apis/schemas/webhook-update.php similarity index 100% rename from inc/api/schemas/webhook-update.php rename to inc/apis/schemas/webhook-update.php diff --git a/inc/apis/trait-mcp-abilities.php b/inc/apis/trait-mcp-abilities.php new file mode 100644 index 000000000..2dfba3b2b --- /dev/null +++ b/inc/apis/trait-mcp-abilities.php @@ -0,0 +1,645 @@ +slug); + } + + /** + * Enable MCP abilities for this entity. + * Should be called by the manager to register abilities. + * + * @since 2.5.0 + * @return void + */ + public function enable_mcp_abilities(): void { + + $is_enabled = \WP_Ultimo\MCP_Adapter::get_instance()->is_mcp_enabled(); + + if (! $is_enabled) { + return; + } + + if (! function_exists('wp_register_ability')) { + return; + } + + add_action('wp_abilities_api_categories_init', [$this, 'register_ability_category'], 10, 0); + add_action('wp_abilities_api_init', [$this, 'register_abilities'], 10, 0); + } + + /** + * Register the ability category for this entity. + * + * @since 2.5.0 + * @return void + */ + public function register_ability_category(): void { + + if (! function_exists('wp_register_ability_category')) { + return; + } + + if (wp_has_ability_category('multisite-ultimate')) { + return; + } + wp_register_ability_category( + 'multisite-ultimate', + [ + 'label' => __('Multisite Ultimate', 'ultimate-multisite'), + 'description' => __('CRUD operations for Multisite Ultimate entities including customers, sites, products, memberships, and more.', 'ultimate-multisite'), + ] + ); + } + + /** + * Permission callback for MCP abilities. + * Checks if the current user has the required capabilities. + * + * @since 2.5.0 + * @param array $input_data The input data passed to the ability. + * @return bool|\WP_Error True if user has permission, WP_Error otherwise. + */ + public function mcp_permission_callback(array $input_data) { + unset($input_data); + + $capability = "wu_read_{$this->slug}"; + + if (! current_user_can($capability) && ! current_user_can('manage_network')) { + return new \WP_Error( + 'rest_forbidden', + __('You do not have permission to access this resource.', 'ultimate-multisite'), + ['status' => 403] + ); + } + + return true; + } + + /** + * Register abilities with the Abilities API. + * + * @since 2.5.0 + * @return void + */ + public function register_abilities(): void { + + $ability_prefix = $this->get_mcp_ability_prefix(); + $model_name = $this->slug; + $display_name = ucwords(str_replace(['_', '-'], ' ', $model_name)); + + if (in_array('get_item', $this->enabled_mcp_abilities, true)) { + $this->register_get_item_ability($ability_prefix, $display_name); + } + + if (in_array('get_items', $this->enabled_mcp_abilities, true)) { + $this->register_get_items_ability($ability_prefix, $display_name); + } + + if (in_array('create_item', $this->enabled_mcp_abilities, true)) { + $this->register_create_item_ability($ability_prefix, $display_name); + } + + if (in_array('update_item', $this->enabled_mcp_abilities, true)) { + $this->register_update_item_ability($ability_prefix, $display_name); + } + + if (in_array('delete_item', $this->enabled_mcp_abilities, true)) { + $this->register_delete_item_ability($ability_prefix, $display_name); + } + + /** + * Fires after MCP abilities are registered for an entity. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $model_name The model name. + * @param object $this The manager instance. + */ + do_action('wu_mcp_abilities_registered', $ability_prefix, $model_name, $this); + } + + /** + * Register the get item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_get_item_ability(string $ability_prefix, string $display_name): void { + + wp_register_ability( + "$ability_prefix-get-item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Get %s by ID', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Retrieve a single %s by its ID', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_get_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to retrieve', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + ], + ], + 'required' => ['id'], + ], + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'item' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], + ], + ], + 'meta' => [ + 'mcp' => [ + 'public' => true, // Expose via MCP (required for MCP access) + 'type' => 'tool', + ], + ], + ] + ); + } + + /** + * Register the get items ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_get_items_ability(string $ability_prefix, string $display_name): void { + + wp_register_ability( + "$ability_prefix-get-items", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('List %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Retrieve a list of %s with optional filters', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_get_items'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'per_page' => [ + 'description' => __('Number of items to retrieve per page', 'ultimate-multisite'), + 'type' => 'integer', + 'default' => 10, + ], + 'page' => [ + 'description' => __('Page number to retrieve', 'ultimate-multisite'), + 'type' => 'integer', + 'default' => 1, + ], + ], + ], + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Array of %s objects', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'array', + 'items' => [ + 'type' => 'object', + ], + ], + 'total' => [ + 'description' => __('Total number of items', 'ultimate-multisite'), + 'type' => 'integer', + ], + ], + ], + ] + ); + } + + /** + * Register the create item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_create_item_ability(string $ability_prefix, string $display_name): void { + + $input_schema = $this->get_mcp_schema_for_ability('create'); + + wp_register_ability( + "$ability_prefix-create-item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Create %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Create a new %s', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_create_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => $input_schema, + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'item' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The created %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], + ], + ], + ] + ); + } + + /** + * Register the update item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_update_item_ability(string $ability_prefix, string $display_name): void { + + $input_schema = $this->get_mcp_schema_for_ability('update'); + + // Add ID to the properties + $input_schema['properties']['id'] = [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to update', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + ]; + + // Add ID to required fields + if (! isset($input_schema['required'])) { + $input_schema['required'] = []; + } + $input_schema['required'][] = 'id'; + + wp_register_ability( + "$ability_prefix-update-item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Update %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Update an existing %s', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_update_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => $input_schema, + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'item' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The updated %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], + ], + ], + ] + ); + } + + /** + * Register the delete item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_delete_item_ability(string $ability_prefix, string $display_name): void { + + wp_register_ability( + "$ability_prefix-delete-item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Delete %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Delete a %s by its ID', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_delete_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to delete', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + ], + ], + 'required' => ['id'], + ], + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'success' => [ + 'description' => __('Whether the deletion was successful', 'ultimate-multisite'), + 'type' => 'boolean', + ], + ], + ], + ] + ); + } + + /** + * Get MCP schema for an ability (create or update). + * Returns JSON Schema format for input_schema. + * + * @since 2.5.0 + * @param string $context The context (create or update). + * @return array + */ + protected function get_mcp_schema_for_ability(string $context = 'create'): array { + + if (! method_exists($this, 'get_arguments_schema')) { + return []; + } + + $rest_schema = $this->get_arguments_schema('update' === $context); + + $properties = []; + $required = []; + + foreach ($rest_schema as $key => $args) { + $properties[ $key ] = [ + 'description' => $args['description'] ?? ucfirst(str_replace('_', ' ', $key)), + 'type' => $args['type'] ?? 'string', + ]; + + if (isset($args['default'])) { + $properties[ $key ]['default'] = $args['default']; + } + + if (isset($args['enum'])) { + $properties[ $key ]['enum'] = $args['enum']; + } + + if (isset($args['required']) && $args['required']) { + $required[] = $key; + } + } + + $schema = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if (! empty($required)) { + $schema['required'] = $required; + } + + return $schema; + } + + /** + * MCP callback to get a single item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_get_item(array $args) { + + if (! isset($args['id'])) { + return new \WP_Error('missing_id', __('ID is required', 'ultimate-multisite')); + } + + $item = $this->model_class::get_by_id($args['id']); + + if (empty($item)) { + return new \WP_Error( + "wu_{$this->slug}_not_found", + sprintf( + // translators: %s: entity name (e.g., Customer, Site, Product) + __('%s not found', 'ultimate-multisite'), + ucfirst(str_replace('_', ' ', $this->slug)) + ) + ); + } + + return [ + 'item' => $item->to_array(), + ]; + } + + /** + * MCP callback to get a list of items. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array + */ + public function mcp_get_items(array $args): array { + + $query_args = array_merge( + [ + 'per_page' => 10, + 'page' => 1, + ], + $args + ); + + $items = $this->model_class::query($query_args); + + $total = $this->model_class::query(array_merge($query_args, ['count' => true])); + + return [ + 'items' => array_map( + function ($item) { + return $item->to_array(); + }, + $items + ), + 'total' => $total, + ]; + } + + /** + * MCP callback to create an item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_create_item(array $args) { + + $model_name = (new $this->model_class([]))->model; + + $saver_function = "wu_create_{$model_name}"; + + if (function_exists($saver_function)) { + $item = call_user_func($saver_function, $args); + + $saved = is_wp_error($item) ? $item : true; + } else { + $item = new $this->model_class($args); + + $saved = $item->save(); + } + + if (is_wp_error($saved)) { + return $saved; + } + + if (! $saved) { + return new \WP_Error( + "wu_{$this->slug}_save_failed", + __('Failed to save item', 'ultimate-multisite') + ); + } + + return [ + 'item' => $item->to_array(), + ]; + } + + /** + * MCP callback to update an item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_update_item(array $args) { + + if (! isset($args['id'])) { + return new \WP_Error('missing_id', __('ID is required', 'ultimate-multisite')); + } + + $id = $args['id']; + unset($args['id']); + + $item = $this->model_class::get_by_id($id); + + if (empty($item)) { + return new \WP_Error( + "wu_{$this->slug}_not_found", + sprintf( + // translators: %s: entity name (e.g., Customer, Site, Product) + __('%s not found', 'ultimate-multisite'), + ucfirst(str_replace('_', ' ', $this->slug)) + ) + ); + } + + foreach ($args as $param => $value) { + $set_method = "set_{$param}"; + + if ('meta' === $param) { + $item->update_meta_batch($value); + } elseif (method_exists($item, $set_method)) { + call_user_func([$item, $set_method], $value); + } + } + + $saved = $item->save(); + + if (is_wp_error($saved)) { + return $saved; + } + + if (! $saved) { + return new \WP_Error( + "wu_{$this->slug}_save_failed", + __('Failed to update item', 'ultimate-multisite') + ); + } + + return [ + 'item' => $item->to_array(), + ]; + } + + /** + * MCP callback to delete an item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_delete_item(array $args) { + + if (! isset($args['id'])) { + return new \WP_Error('missing_id', __('ID is required', 'ultimate-multisite')); + } + + $item = $this->model_class::get_by_id($args['id']); + + if (empty($item)) { + return new \WP_Error( + "wu_{$this->slug}_not_found", + sprintf( + // translators: %s: entity name (e.g., Customer, Site, Product) + __('%s not found', 'ultimate-multisite'), + ucfirst(str_replace('_', ' ', $this->slug)) + ) + ); + } + + $result = $item->delete(); + + return [ + 'success' => ! is_wp_error($result) && $result, + ]; + } +} diff --git a/inc/api/trait-rest-api.php b/inc/apis/trait-rest-api.php similarity index 100% rename from inc/api/trait-rest-api.php rename to inc/apis/trait-rest-api.php diff --git a/inc/api/trait-wp-cli.php b/inc/apis/trait-wp-cli.php similarity index 100% rename from inc/api/trait-wp-cli.php rename to inc/apis/trait-wp-cli.php diff --git a/inc/class-api.php b/inc/class-api.php index 953623236..b651d84a8 100644 --- a/inc/class-api.php +++ b/inc/class-api.php @@ -368,7 +368,7 @@ public function maybe_log_api_call($request): void { * @param string|array $handler The callback. * @param \WP_REST_Request $request The request object. * - * @return mixed + * @return void */ public function log_api_errors($result, $handler, $request) { @@ -389,7 +389,6 @@ public function log_api_errors($result, $handler, $request) { wu_log_add('api-errors', $result); } - return $result; } /** diff --git a/inc/class-autoloader.php b/inc/class-autoloader.php index 233d88a61..c8a1f442c 100644 --- a/inc/class-autoloader.php +++ b/inc/class-autoloader.php @@ -9,8 +9,6 @@ namespace WP_Ultimo; -use Pablo_Pacheco\WP_Namespace_Autoloader\WP_Namespace_Autoloader; - // Exit if accessed directly defined('ABSPATH') || exit; diff --git a/inc/class-mcp-adapter.php b/inc/class-mcp-adapter.php new file mode 100644 index 000000000..59fca6108 --- /dev/null +++ b/inc/class-mcp-adapter.php @@ -0,0 +1,331 @@ +is_mcp_enabled()) { + return; + } + + try { + add_action('mcp_adapter_init', array($this, 'initialize_mcp_server')); + $this->adapter = McpAdapterCore::instance(); + + /** + * Fires after the MCP adapter is initialized. + * + * Allows other plugins and themes to register their own abilities. + * + * @since 2.5.0 + * @param MCP_Adapter $mcp_adapter The MCP adapter instance. + */ + do_action('wu_mcp_adapter_initialized', $this); + } catch (\Exception $e) { + wu_log_add( + 'mcp-adapter', + sprintf( + // translators: %s: error message from the exception + __('Failed to initialize MCP adapter: %s', 'ultimate-multisite'), + $e->getMessage() + ) + ); + } + } + + /** + * Init MCP. + * + * @return void + */ + public function initialize_mcp_server(): void { + $abilities_ids = $this->get_mcp_abilities(); + + // Bail if no abilities are available. + if ( empty($abilities_ids) ) { + return; + } + + add_filter('rest_pre_dispatch', [$this, 'rest_pre_dispatch_save_request'], 10, 3); + + /* + * Temporarily disable MCP validation during server creation. + * Workaround for validator bug with union types (e.g., ["integer", "null"]). + * This will be removed once the mcp-adapter validator bug is fixed. + * + * @see https://github.com/WordPress/mcp-adapter/issues/47 + */ + add_filter('mcp_validation_enabled', '__return_false', 999); + + try { + // Create MCP server. + $this->adapter->create_server( + 'ultimate-multisite-server', + 'ultimate-multisite', + 'mcp-adapter', + __('Ultimate Multisite MCP Server', 'ultimate-multisite'), + __('AI-accessible Ultimate Multisite operations via MCP', 'ultimate-multisite'), + '1.0.0', + [HttpTransport::class], + \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class, + \WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class, + $abilities_ids, + [], + [], + [$this, 'permission_callback'] + ); + } catch ( \Throwable $e ) { + wu_log_add( + 'mcp', + 'MCP server initialization failed: ' . $e->getMessage(), + LogLevel::ERROR + ); + } finally { + // Re-enable MCP validation immediately after server creation. + remove_filter('mcp_validation_enabled', '__return_false', 999); + } + } + + /** + * Add the admin interface to configure MCP adapter. + * + * @since 2.5.0 + * @return void + */ + public function add_settings(): void { + + wu_register_settings_field( + 'api', + 'mcp_header', + [ + 'title' => __('MCP Adapter Settings', 'ultimate-multisite'), + 'desc' => __('Options related to the Model Context Protocol (MCP) adapter. MCP allows AI assistants to interact with Ultimate Multisite programmatically.', 'ultimate-multisite'), + 'type' => 'header', + ] + ); + + wu_register_settings_field( + 'api', + 'enable_mcp', + [ + 'title' => __('Enable MCP Adapter', 'ultimate-multisite'), + 'desc' => __('Tick this box to enable the Model Context Protocol (MCP) adapter. This allows AI assistants to interact with Ultimate Multisite through the Abilities API.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + ] + ); + wu_register_settings_field( + 'api', + 'mcp_serveer_urel', + [ + 'title' => __('MCP Server URL', 'ultimate-multisite'), + 'desc' => '', + 'tooltip' => __('This is the URL where the MCP server is accessible via HTTP.', 'ultimate-multisite'), + 'copy' => true, + 'type' => 'text-display', + 'default' => rest_url('mcp/mcp-adapter-default-server'), + 'require' => [ + 'enable_mcp' => 1, + ], + ] + ); + wu_register_settings_field( + 'api', + 'mcp_stdio_commande', + [ + 'title' => __('STDIO Command', 'ultimate-multisite'), + 'desc' => '', + 'tooltip' => __('This is the WP-CLI command to run the MCP server via STDIO transport.', 'ultimate-multisite'), + 'copy' => true, + 'type' => 'text-display', + 'default' => 'wp mcp-adapter serve --server=mcp-adapter-default-server --user=admin', + 'require' => [ + 'enable_mcp' => 1, + ], + ] + ); + } + + /** + * Get the MCP adapter instance. + * + * @since 2.5.0 + * @return McpAdapterCore|null + */ + public function get_adapter(): ?McpAdapterCore { + + return $this->adapter; + } + + /** + * Checks if the MCP adapter is enabled. + * + * @since 2.5.0 + * @return bool + */ + public function is_mcp_enabled(): bool { + + /** + * Allow plugin developers to force a given state for the MCP adapter. + * + * @since 2.5.0 + * @param bool $enabled Whether the MCP adapter is enabled. + * @return bool + */ + return apply_filters('wu_is_mcp_enabled', wu_get_setting('enable_mcp', false)); + } + + + /** + * Get abilities for MCP server. + * + * Filters abilities to include only those with 'wu_' namespace by default, + * with a filter to allow inclusion of abilities from other namespaces. + * + * @return array Array of ability IDs for MCP server. + */ + private function get_mcp_abilities(): array { + + // Check if the abilities API is available. + if ( ! function_exists('wp_get_abilities') ) { + return array(); + } + + $all_abilities_ids = array_keys(wp_get_abilities()); + + // Filter abilities based on namespace and custom filter. + $mcp_abilities = array_filter( + $all_abilities_ids, + function ($ability_id) { + // Include Ultimate Multisite abilities by default. + $include = str_starts_with($ability_id, 'multisite-ultimate/'); + + // Allow filter to override inclusion decision. + /** + * Filter to override MCP ability inclusion decision. + * + * @since 2.4.8 + * @param bool $include Whether to include the ability. + * @param string $ability_id The ability ID. + */ + return apply_filters('wu_mcp_include_ability', $include, $ability_id); + } + ); + + // Re-index array. + return array_values($mcp_abilities); + } + + /** + * Use API Key. + * + * @param \WP_REST_Request|null $request The Request. + * + * @return bool|\WP_Error + */ + public function permission_callback(\WP_REST_Request $request = null) { + if ($request) { + $this->current_request = $request; + } + if (! $this->current_request) { + return new \WP_Error('no_request_object', 'Request Object lost', ['status' => 500]); + } + + $result = API::get_instance()->check_authorization($this->current_request); + + if ( ! $result) { + return new \WP_Error('invalid_api_key', 'Invalid API key', ['status' => 403]); + } + + return $result; + } + + /** + * For backwards compatibility we use pre dispatch to save the request object. + * + * @param \WP_REST_Response $result The result. + * @param \WP_REST_Server $server The server. + * @param \WP_REST_Request $request The request. + * + * @return mixed + */ + public function rest_pre_dispatch_save_request($result, $server, $request) { + $this->current_request = $request; + return $result; + } +} diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 7cadbd4bb..ec2b1b9c3 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -438,6 +438,8 @@ protected function load_extra_components(): void { */ WP_Ultimo\SSO\SSO::get_instance(); + WP_Ultimo\SSO\Magic_Link::get_instance(); + /* * Loads the debugger tools */ @@ -642,6 +644,8 @@ function () { * Cron Schedules */ \WP_Ultimo\Cron::get_instance(); + + \WP_Ultimo\MCP_Adapter::get_instance(); } /** @@ -666,6 +670,11 @@ protected function load_admin_pages(): void { */ new WP_Ultimo\Admin_Pages\Top_Admin_Nav_Menu(); + /* + * Initialize magic links for admin bar My Sites menu. + */ + \WP_Ultimo\SSO\Admin_Bar_Magic_Links::get_instance(); + /* * Loads the Checkout Form admin page. */ diff --git a/inc/country/class-country.php b/inc/country/class-country.php index aba3565d6..19db79d48 100644 --- a/inc/country/class-country.php +++ b/inc/country/class-country.php @@ -80,7 +80,7 @@ public function get_states() { * * @param array $states List of states in a XX => Name format. * @param string $country_code Two-letter ISO code for the country. - * @param WP_Ultimo\Country\Country $current_country Instance of the current class. + * @param \WP_Ultimo\Country\Country $current_country Instance of the current class. * @return array The filtered list of states. */ return apply_filters('wu_country_get_states', $states, $this->country_code, $this); @@ -140,7 +140,7 @@ public function get_cities($state_code = '') { * @param array $cities List of state city names. No keys are present. * @param string $country_code Two-letter ISO code for the country. * @param string $state_code Two-letter ISO code for the state. - * @param WP_Ultimo\Country\Country $current_country Instance of the current class. + * @param \WP_Ultimo\Country\Country $current_country Instance of the current class. * @return array The filtered list of states. */ return apply_filters('wu_country_get_cities', $cities, $this->country_code, $state_code, $this); @@ -238,14 +238,14 @@ public function get_administrative_division_name($state_code = null, $ucwords = /** * Returns nice name of the country administrative sub-divisions. * - * @since 2.0.11 - * * @param string $name The division name. Usually something like state, province, region, etc. * @param string $country_code Two-letter ISO code for the country. * @param string $state_code Two-letter ISO code for the state. - * @param WP_Ultimo\Country\Country $current_country Instance of the current class. - * @param bool $current_country Instance of the current class. + * @param bool $ucwords if we uppercase the words. + * @param \WP_Ultimo\Country\Country $current_country Instance of the current class. + * * @return string The modified division name. + *@since 2.0.11 */ return apply_filters('wu_country_get_administrative_division_name', $name, $this->country_code, $state_code, $ucwords, $this); } diff --git a/inc/functions/domain.php b/inc/functions/domain.php index 8cdf29dd7..7734326b3 100644 --- a/inc/functions/domain.php +++ b/inc/functions/domain.php @@ -30,7 +30,7 @@ function wu_get_domain($domain_id) { * @since 2.0.0 * * @param array $query Query arguments. - * @return \WP_Ultimo\Models\Domain[] + * @return \WP_Ultimo\Models\Domain[]|string[]|int */ function wu_get_domains($query = []) { diff --git a/inc/functions/settings.php b/inc/functions/settings.php index 7dd0edcc5..fc5592859 100644 --- a/inc/functions/settings.php +++ b/inc/functions/settings.php @@ -6,8 +6,6 @@ * @since 2.0.0 */ -use WP_Ultimo\Dependencies\Intervention\Image\ImageManagerStatic as Image; - // Exit if accessed directly defined('ABSPATH') || exit; @@ -33,12 +31,12 @@ function wu_get_all_settings() { * @since 2.0.0 * * @param string $setting Settings name to return. - * @param mixed $default Default value for the setting if it doesn't exist. + * @param mixed $default_value Default value for the setting if it doesn't exist. * @return mixed The value of that setting */ -function wu_get_setting($setting, $default = false) { +function wu_get_setting($setting, $default_value = false) { - return WP_Ultimo()->settings->get_setting($setting, $default); + return WP_Ultimo()->settings->get_setting($setting, $default_value); } /** diff --git a/inc/functions/url.php b/inc/functions/url.php index 96a7b0a0c..08de312b3 100644 --- a/inc/functions/url.php +++ b/inc/functions/url.php @@ -93,3 +93,70 @@ function wu_ajax_url($when = null, $query_args = [], $site_id = false, $scheme = return apply_filters('wu_ajax_url', $url, $query_args, $when, $site_id); } + + +/** + * Adds a magic link if needed. + * + * @param ?int $blog_id Blog id. + * @param string $path Path. + * @param ?string $scheme Scheme. + * + * @return false|string + */ +function wu_get_admin_url($blog_id = null, $path = '', $scheme = 'admin') { + if (! $blog_id) { + return get_admin_url($blog_id, $path, $scheme); + } + + $current_user_id = get_current_user_id(); + $admin_url = get_admin_url($blog_id, $path, $scheme); + + if ( ! $current_user_id ) { + return $admin_url; + } + + $magic_link = \WP_Ultimo\SSO\Magic_Link::get_instance(); + + // Check if a magic link is needed. + if ( ! $magic_link->site_needs_magic_link($blog_id) ) { + return $admin_url; + } + + $magic_link_url = $magic_link->generate_magic_link($current_user_id, $blog_id, $admin_url); + + return $magic_link_url ?: get_admin_url($blog_id, $path, $scheme); +} + +/** + * Adds a magic link if needed. + * + * @param ?int $blog_id Blog id. + * + * @return false|string + */ +function wu_get_home_url($blog_id = null) { + $site = wu_get_site($blog_id); + + if (! $site) { + return get_home_url($blog_id); + } + + $home_url = $site->get_active_site_url(); + $current_user_id = get_current_user_id(); + + if ( ! $current_user_id) { + return $home_url; + } + + $magic_link = \WP_Ultimo\SSO\Magic_Link::get_instance(); + + // Check if magic link is needed. + if ( ! $magic_link->site_needs_magic_link($blog_id) ) { + return $home_url; + } + + $magic_link_url = $magic_link->generate_magic_link($current_user_id, $blog_id, $home_url); + + return $magic_link_url ?: get_home_url($blog_id); +} diff --git a/inc/helpers/class-validator.php b/inc/helpers/class-validator.php index 8f279eb57..48af911ba 100644 --- a/inc/helpers/class-validator.php +++ b/inc/helpers/class-validator.php @@ -36,7 +36,7 @@ class Validator { * Holds an instance of the validator object. * * @since 2.0.0 - * @var Rakit\Validation\Validator + * @var \Rakit\Validation\Validator */ protected $validator; @@ -44,7 +44,7 @@ class Validator { * Holds an instance of the validation being performed. * * @since 2.0.0 - * @var Rakit\Validation\Validation + * @var \Rakit\Validation\Validation */ protected $validation; @@ -181,7 +181,7 @@ protected function cast_to_wp_error($errors) { * Get holds an instance of the validation being performed. * * @since 2.0.0 - * @return Rakit\Validation\Validation + * @return \Rakit\Validation\Validation */ public function get_validation() { diff --git a/inc/integrations/host-providers/class-gridpane-host-provider.php b/inc/integrations/host-providers/class-gridpane-host-provider.php index 9d5d8808b..8cb375dc6 100644 --- a/inc/integrations/host-providers/class-gridpane-host-provider.php +++ b/inc/integrations/host-providers/class-gridpane-host-provider.php @@ -9,8 +9,6 @@ namespace WP_Ultimo\Integrations\Host_Providers; -use WP_Ultimo\Integrations\Host_Providers\Base_Host_Provider; - // Exit if accessed directly defined('ABSPATH') || exit; @@ -137,7 +135,7 @@ public function send_gridpane_api_request($endpoint, $data = [], $method = 'POST * @since 2.0.0 * @param string $domain The domain name being mapped. * @param int $site_id ID of the site that is receiving that mapping. - * @return object\WP_Error + * @return object|\WP_Error */ public function on_add_domain($domain, $site_id) { @@ -157,7 +155,7 @@ public function on_add_domain($domain, $site_id) { * @since 2.0.0 * @param string $domain The domain name being removed. * @param int $site_id ID of the site that is receiving that mapping. - * @return object\WP_Error + * @return object|\WP_Error */ public function on_remove_domain($domain, $site_id) { diff --git a/inc/list-tables/class-base-list-table.php b/inc/list-tables/class-base-list-table.php index 29838ab69..86f86ee8f 100644 --- a/inc/list-tables/class-base-list-table.php +++ b/inc/list-tables/class-base-list-table.php @@ -223,7 +223,7 @@ public function get_label($label = 'singular') { * @param integer $per_page Number of items to display per page. * @param integer $page_number Current page. * @param boolean $count If we should count records or return the actual records. - * @return array + * @return array|int */ public function get_items($per_page = 5, $page_number = 1, $count = false) { @@ -273,7 +273,7 @@ public function get_items($per_page = 5, $page_number = 1, $count = false) { * @param array $query_args The query args. * @return mixed */ - protected function _get_items($query_args) { + protected function _get_items($query_args) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore $query_class = new $this->query_class(); @@ -518,10 +518,22 @@ public function display(): void { } } + /** + * Returns the filters for this page. + * + * @since 2.0.0 + */ + public function get_filters(): array { + + return [ + 'filters' => [], + 'date_filters' => [], + ]; + } + /** * Display the filters if they exist. * - * @todo: refator * @since 2.0.0 * @return void */ @@ -635,7 +647,7 @@ public function process_single_action() {} * Handles the bulk processing. * * @since 2.0.0 - * @return bool + * @return bool|\WP_Error */ public static function process_bulk_action() { @@ -824,7 +836,7 @@ public function column_default($item, $column_name) { * @param string $date Valid date to be used inside a strtotime call. * @return string */ - public function _column_datetime($date) { + public function _column_datetime($date) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore if ( ! wu_validate_date($date)) { return __('--', 'ultimate-multisite'); @@ -1150,7 +1162,7 @@ public function column_cb($item) { * * @since 2.0.0 */ - public function _get_js_var_name(): string { + public function _get_js_var_name(): string { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore return str_replace('-', '_', $this->id); } @@ -1161,7 +1173,7 @@ public function _get_js_var_name(): string { * @since 2.0.0 * @return void */ - public function _js_vars(): void { + public function _js_vars(): void { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore /** * Call the parent method for backwards compat. @@ -1305,16 +1317,6 @@ public function get_sortable_columns() { public function get_extra_fields() { return []; - - $_filter_fields = []; - - if (isset($filters['filters'])) { - foreach ($filters['filters'] as $field_name => $field) { - $_filter_fields[ $field_name ] = wu_request($field_name, ''); - } - } - - return $_filter_fields; } /** diff --git a/inc/list-tables/class-checkout-form-list-table.php b/inc/list-tables/class-checkout-form-list-table.php index 2311ca563..d254d5aaa 100644 --- a/inc/list-tables/class-checkout-form-list-table.php +++ b/inc/list-tables/class-checkout-form-list-table.php @@ -52,7 +52,7 @@ public function __construct() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. */ public function column_name($item): string { @@ -89,7 +89,7 @@ public function column_name($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. * @return string */ public function column_slug($item) { @@ -104,7 +104,7 @@ public function column_slug($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. */ public function column_steps($item): string { // translators: %1$d: number of steps, %2$d: number of fields @@ -116,7 +116,7 @@ public function column_steps($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. */ public function column_shortcode($item): string { @@ -128,7 +128,7 @@ public function column_shortcode($item): string { __('Copy to the Clipboard', 'ultimate-multisite') ); - return sprintf('', esc_attr($item->get_shortcode()), ''); + return sprintf('', esc_attr($item->get_shortcode())); } /** @@ -203,17 +203,4 @@ public function get_columns() { return $columns; } - - /** - * Returns the filters for this page. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } } diff --git a/inc/list-tables/class-customers-site-list-table.php b/inc/list-tables/class-customers-site-list-table.php index a151a11bd..827a80bc8 100644 --- a/inc/list-tables/class-customers-site-list-table.php +++ b/inc/list-tables/class-customers-site-list-table.php @@ -84,7 +84,7 @@ public function column_responsive($item): void { 'icon' => 'dashicons-wu-browser wu-align-middle wu-mr-1', 'label' => __('Go to the Dashboard', 'ultimate-multisite'), 'value' => __('Dashboard', 'ultimate-multisite'), - 'url' => get_admin_url($item->get_id()), + 'url' => wu_get_admin_url($item->get_id()), ], 'membership' => [ 'icon' => 'dashicons-wu-rotate-ccw wu-align-middle wu-mr-1', diff --git a/inc/list-tables/class-discount-code-list-table.php b/inc/list-tables/class-discount-code-list-table.php index a545e8967..5d5dee308 100644 --- a/inc/list-tables/class-discount-code-list-table.php +++ b/inc/list-tables/class-discount-code-list-table.php @@ -52,7 +52,7 @@ public function __construct() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. */ public function column_name($item): string { @@ -84,7 +84,7 @@ public function column_name($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * * @return string */ @@ -109,7 +109,7 @@ public function column_value($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * * @return string */ @@ -134,7 +134,7 @@ public function column_setup_fee_value($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * @return string */ public function column_uses($item) { @@ -158,7 +158,7 @@ public function column_uses($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * @return string */ public function column_coupon_code($item) { @@ -196,17 +196,4 @@ public function get_columns() { return $columns; } - - /** - * Returns the filters for this page. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } } diff --git a/inc/list-tables/class-domain-list-table.php b/inc/list-tables/class-domain-list-table.php index f81bc3e73..199105ba5 100644 --- a/inc/list-tables/class-domain-list-table.php +++ b/inc/list-tables/class-domain-list-table.php @@ -9,7 +9,6 @@ namespace WP_Ultimo\List_Tables; -use WP_Ultimo\Models\Domain; use WP_Ultimo\Database\Domains\Domain_Stage; // Exit if accessed directly @@ -72,7 +71,7 @@ public function get_extra_query_fields() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. */ public function column_domain($item): string { @@ -98,7 +97,7 @@ public function column_domain($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. * @return string */ public function column_active($item) { @@ -111,7 +110,7 @@ public function column_active($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. * @return string */ public function column_primary_domain($item) { @@ -124,7 +123,7 @@ public function column_primary_domain($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. * @return string */ public function column_secure($item) { diff --git a/inc/list-tables/class-membership-list-table-widget.php b/inc/list-tables/class-membership-list-table-widget.php index 58fc7421a..829bac26b 100644 --- a/inc/list-tables/class-membership-list-table-widget.php +++ b/inc/list-tables/class-membership-list-table-widget.php @@ -115,7 +115,7 @@ public function get_extra_query_fields() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. */ public function column_hash($item): string { @@ -199,7 +199,7 @@ public function column_amount($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_customer($item) { @@ -274,5 +274,5 @@ public function get_columns() { * @since 2.0.0 * @return void */ - public function _js_vars(): void {} + public function _js_vars(): void {} // phpcs:ignore PSR2 } diff --git a/inc/list-tables/class-membership-list-table.php b/inc/list-tables/class-membership-list-table.php index 00815e364..f0f7cad36 100644 --- a/inc/list-tables/class-membership-list-table.php +++ b/inc/list-tables/class-membership-list-table.php @@ -66,7 +66,7 @@ public function get_extra_query_fields() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_hash($item) { @@ -101,7 +101,7 @@ public function column_hash($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_status($item) { @@ -120,7 +120,7 @@ public function column_status($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_amount($item) { diff --git a/inc/list-tables/class-payment-list-table-widget.php b/inc/list-tables/class-payment-list-table-widget.php index b670fca95..cb9ccf71b 100644 --- a/inc/list-tables/class-payment-list-table-widget.php +++ b/inc/list-tables/class-payment-list-table-widget.php @@ -229,9 +229,11 @@ public function get_columns() { * Returns the filters for this page. * * @since 2.0.0 - * @return void. + * @return array */ - public function get_filters() {} + public function get_filters(): array { + return []; + } /** * Overrides the parent method to include the custom ajax functionality for Ultimate Multisite. @@ -239,5 +241,5 @@ public function get_filters() {} * @since 2.0.0 * @return void */ - public function _js_vars(): void {} + public function _js_vars(): void {} // phpcs:ignore PSR2 } diff --git a/inc/list-tables/class-payment-list-table.php b/inc/list-tables/class-payment-list-table.php index f0ddd7d11..053109f7a 100644 --- a/inc/list-tables/class-payment-list-table.php +++ b/inc/list-tables/class-payment-list-table.php @@ -107,7 +107,7 @@ public function column_hash($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Payment $item Payment object. + * @param \WP_Ultimo\Models\Payment $item Payment object. * @return string */ public function column_status($item) { diff --git a/inc/list-tables/class-product-list-table.php b/inc/list-tables/class-product-list-table.php index a29fa8c91..01201992c 100644 --- a/inc/list-tables/class-product-list-table.php +++ b/inc/list-tables/class-product-list-table.php @@ -52,7 +52,7 @@ public function __construct() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_name($item) { @@ -91,7 +91,7 @@ public function column_name($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_type($item) { @@ -108,7 +108,7 @@ public function column_type($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_slug($item) { @@ -123,7 +123,7 @@ public function column_slug($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_amount($item) { @@ -169,7 +169,7 @@ public function column_amount($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_setup_fee($item) { @@ -266,7 +266,7 @@ public function get_columns() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item The line item being displayed. + * @param \WP_Ultimo\Models\Product $item The line item being displayed. * @return void */ public function single_row_grid($item): void { diff --git a/inc/list-tables/class-site-list-table.php b/inc/list-tables/class-site-list-table.php index 55cb7ffb3..8ed6f68f7 100644 --- a/inc/list-tables/class-site-list-table.php +++ b/inc/list-tables/class-site-list-table.php @@ -112,7 +112,7 @@ public function column_cb($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Site $item Site object. + * @param \WP_Ultimo\Models\Site $item Site object. */ public function column_path($item): string { @@ -121,8 +121,6 @@ public function column_path($item): string { 'model' => 'site', ]; - $title = $item->get_title(); - $title = sprintf('%s', wu_network_admin_url('wp-ultimo-edit-site', $url_atts), $item->get_title()); // Concatenate the two blocks @@ -220,7 +218,7 @@ public function column_blog_id($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Site $item Site object. + * @param \WP_Ultimo\Models\Site $item Site object. * @return string */ public function column_type($item) { diff --git a/inc/list-tables/class-webhook-list-table.php b/inc/list-tables/class-webhook-list-table.php index adb841520..258821d43 100644 --- a/inc/list-tables/class-webhook-list-table.php +++ b/inc/list-tables/class-webhook-list-table.php @@ -181,17 +181,4 @@ public function get_columns() { return $columns; } - - /** - * Returns the filters for this page. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } } diff --git a/inc/list-tables/customer-panel/class-product-list-table.php b/inc/list-tables/customer-panel/class-product-list-table.php index ad2eb73ef..a4b026a1c 100644 --- a/inc/list-tables/customer-panel/class-product-list-table.php +++ b/inc/list-tables/customer-panel/class-product-list-table.php @@ -48,19 +48,6 @@ public function get_columns() { return []; } - /** - * Resets the filters. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } - /** * Resets bulk actions. * diff --git a/inc/list-tables/customer-panel/class-site-list-table.php b/inc/list-tables/customer-panel/class-site-list-table.php index 2b88113d2..a9457ec17 100644 --- a/inc/list-tables/customer-panel/class-site-list-table.php +++ b/inc/list-tables/customer-panel/class-site-list-table.php @@ -48,19 +48,6 @@ public function get_columns() { return []; } - /** - * Clears filters. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } - /** * Clears views. * diff --git a/inc/managers/class-broadcast-manager.php b/inc/managers/class-broadcast-manager.php index 9fb4e1d3c..6c46fecc4 100644 --- a/inc/managers/class-broadcast-manager.php +++ b/inc/managers/class-broadcast-manager.php @@ -27,6 +27,7 @@ class Broadcast_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -57,6 +58,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + /** * Add unseen broadcast notices to the panel. */ @@ -97,7 +100,7 @@ public function add_unseen_broadcast_notices(): void { $targets = [$targets]; } - $dismissed = get_user_meta(get_current_user_id(), 'wu_dismissed_admin_notices'); + $dismissed = get_user_meta(get_current_user_id(), 'wu_dismissed_admin_notices', false); if (in_array($current_customer->get_id(), $targets, true) && ! in_array($broadcast->get_id(), $dismissed, true)) { $notice = '' . $broadcast->get_title() . ' ' . $broadcast->get_content() . ''; diff --git a/inc/managers/class-customer-manager.php b/inc/managers/class-customer-manager.php index 7cfe793ac..c09ecb37e 100644 --- a/inc/managers/class-customer-manager.php +++ b/inc/managers/class-customer-manager.php @@ -27,6 +27,7 @@ class Customer_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -57,6 +58,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action( 'init', function () { @@ -118,8 +121,8 @@ public function handle_resend_verification_email(): void { * @return array $response The Heartbeat response */ public function on_heartbeat_send($response) { - - $this->log_ip_and_last_login(wp_get_current_user()); + $user = wp_get_current_user(); + $this->log_ip_and_last_login($user->user_login, $user); return $response; } @@ -127,15 +130,14 @@ public function on_heartbeat_send($response) { /** * Saves the IP address and last_login date onto the user. * - * @since 2.0.0 - * + * @param string $user_login The username of the user that logged in. * @param \WP_User $user The WP User object of the user that logged in. * @return void */ - public function log_ip_and_last_login($user): void { + public function log_ip_and_last_login($user_login, $user): void { if ( ! is_a($user, '\WP_User')) { - $user = get_user_by('login', $user); + $user = get_user_by('login', $user_login); } if ( ! $user) { diff --git a/inc/managers/class-discount-code-manager.php b/inc/managers/class-discount-code-manager.php index f2ca6f32b..ec0f2e96f 100644 --- a/inc/managers/class-discount-code-manager.php +++ b/inc/managers/class-discount-code-manager.php @@ -26,6 +26,7 @@ class Discount_Code_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -56,6 +57,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('wu_gateway_payment_processed', [$this, 'maybe_add_use_on_payment_received']); } diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 4093977fb..6cc19a346 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -28,6 +28,7 @@ class Domain_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -132,6 +133,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + $this->set_cookie_domain(); add_action('plugins_loaded', [$this, 'load_integrations']); @@ -536,6 +539,17 @@ public function add_sso_settings(): void { ], ] ); + + wu_register_settings_field( + 'sso', + 'enable_magic_links', + [ + 'title' => __('Enable Magic Links', 'ultimate-multisite'), + 'desc' => __('Enables magic link authentication for custom domains. Magic links provide a fallback authentication method for browsers that don\'t support third-party cookies. When enabled, dashboard and site links will automatically log users in when accessing sites with custom domains. Tokens are cryptographically secure, one-time use, and expire after 10 minutes.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 1, + ] + ); } /** diff --git a/inc/managers/class-event-manager.php b/inc/managers/class-event-manager.php index 050d555ef..3d6d25dd0 100644 --- a/inc/managers/class-event-manager.php +++ b/inc/managers/class-event-manager.php @@ -11,6 +11,7 @@ namespace WP_Ultimo\Managers; +use Psr\Log\LogLevel; use WP_Ultimo\Models\Base_Model; use WP_Ultimo\Models\Event; @@ -26,8 +27,10 @@ class Event_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; + const LOG_FILE_NAME = 'events'; /** * The manager slug. * @@ -72,6 +75,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('init', [$this, 'register_all_events']); add_action('wp_ajax_wu_get_event_payload_preview', [$this, 'event_payload_preview']); @@ -248,22 +253,24 @@ public function get_event_type_as_options() { * @param string $slug The slug of the event. Something like payment_received. * @param array $payload with the events information. * - * @return array with returns message for now. + * @return bool */ public function do_event($slug, $payload) { $registered_event = $this->get_event($slug); if ( ! $registered_event) { - return ['error' => 'Event not found']; + wu_log_add(self::LOG_FILE_NAME, 'Event not found', LogLevel::ERROR); + return false; } $payload_diff = array_diff_key(wu_maybe_lazy_load_payload($registered_event['payload']), $payload); - if (isset($payload_diff[0])) { + if (! empty($payload_diff[0])) { foreach ($payload_diff[0] as $diff_key => $diff_value) { - return ['error' => 'Param required:' . $diff_key]; + wu_log_add(self::LOG_FILE_NAME, 'Param required:' . $diff_key, LogLevel::ERROR); } + return false; } $payload['wu_version'] = wu_get_version(); @@ -275,7 +282,7 @@ public function do_event($slug, $payload) { /** * Saves in the database */ - $this->save_event($slug, $payload); + return $this->save_event($slug, $payload); } /** @@ -334,9 +341,9 @@ public function get_event($slug) { * * @param string $slug of the event. * @param array $payload with event params. - * @return void. + * @return bool. */ - public function save_event($slug, $payload): void { + public function save_event($slug, $payload): bool { $event = new Event( [ @@ -349,7 +356,11 @@ public function save_event($slug, $payload): void { ] ); - $event->save(); + $return = $event->save(); + if ( is_wp_error($return)) { + return false; + } + return (bool) $return; } /** @@ -590,7 +601,7 @@ public function get_model_payload(string $model, ?object $model_object = null) { * * @since 2.0.0 */ - public function clean_old_events(): bool { + public function clean_old_events(): void { /* * Add a filter setting this to 0 or false * to prevent old events from being ever deleted. @@ -598,7 +609,7 @@ public function clean_old_events(): bool { $threshold_days = apply_filters('wu_events_threshold_days', 1); if (empty($threshold_days)) { - return false; + return; } $events_to_remove = wu_get_events( @@ -624,8 +635,6 @@ public function clean_old_events(): bool { // Translators: 1: Number of successfully removed events. 2: Number of failed events to remove. wu_log_add('wu-cron', sprintf(__('Removed %1$d events successfully. Failed to remove %2$d events.', 'ultimate-multisite'), $success_count, count($events_to_remove) - $success_count)); - - return true; } /** diff --git a/inc/managers/class-membership-manager.php b/inc/managers/class-membership-manager.php index ea4c4d922..fb5c416e6 100644 --- a/inc/managers/class-membership-manager.php +++ b/inc/managers/class-membership-manager.php @@ -26,8 +26,10 @@ class Membership_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; + const LOG_FILE_NAME = 'memberships'; /** * The manager slug. * @@ -56,6 +58,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action( 'init', function () { @@ -120,14 +124,15 @@ public function publish_pending_site(): void { * @since 2.0.0 * * @param int $membership_id The membership id. - * @return bool|\WP_Error + * @return void */ public function async_publish_pending_site($membership_id) { $membership = wu_get_membership($membership_id); if ( ! $membership) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $status = $membership->publish_pending_site(); @@ -135,8 +140,6 @@ public function async_publish_pending_site($membership_id) { if (is_wp_error($status)) { wu_log_add('site-errors', $status, LogLevel::ERROR); } - - return $status; } /** @@ -176,7 +179,7 @@ public function check_pending_site_created() { * @since 2.0.0 * * @param int $membership_id The membership id. - * @return bool|\WP_Error + * @return void */ public function async_membership_swap($membership_id) { @@ -185,13 +188,15 @@ public function async_membership_swap($membership_id) { $membership = wu_get_membership($membership_id); if ( ! $membership) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $scheduled_swap = $membership->get_scheduled_swap(); if (empty($scheduled_swap)) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $order = $scheduled_swap->order; @@ -205,13 +210,14 @@ public function async_membership_swap($membership_id) { if (is_wp_error($status)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, $status->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $exception) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, $exception->getMessage(), LogLevel::ERROR); + return; } /* @@ -220,8 +226,6 @@ public function async_membership_swap($membership_id) { $membership->delete_scheduled_swap(); $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } /** @@ -292,7 +296,7 @@ public function mark_cancelled_date($old_value, $new_value, $item_id): void { * * @param int $membership_id The ID of the membership being transferred. * @param int $target_customer_id The new owner. - * @return mixed + * @return void */ public function async_transfer_membership($membership_id, $target_customer_id) { @@ -303,7 +307,8 @@ public function async_transfer_membership($membership_id, $target_customer_id) { $target_customer = wu_get_customer($target_customer_id); if ( ! $membership || ! $target_customer || absint($membership->get_customer_id()) === absint($target_customer->get_id())) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -330,8 +335,8 @@ public function async_transfer_membership($membership_id, $target_customer_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } @@ -345,19 +350,18 @@ public function async_transfer_membership($membership_id, $target_customer_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return new \WP_Error('exception', $e->getMessage()); + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $membership->unlock(); - - return true; } /** @@ -366,7 +370,7 @@ public function async_transfer_membership($membership_id, $target_customer_id) { * @since 2.0.0 * * @param int $membership_id The ID of the membership being deleted. - * @return mixed + * @return void */ public function async_delete_membership($membership_id) { @@ -375,7 +379,8 @@ public function async_delete_membership($membership_id) { $membership = wu_get_membership($membership_id); if ( ! $membership) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -400,8 +405,8 @@ public function async_delete_membership($membership_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } @@ -412,17 +417,15 @@ public function async_delete_membership($membership_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return new \WP_Error('exception', $e->getMessage()); + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); + return; } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } } diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index b2c30077a..ac2861a2a 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -11,6 +11,7 @@ namespace WP_Ultimo\Managers; +use Psr\Log\LogLevel; use WP_Ultimo\Managers\Base_Manager; use WP_Ultimo\Models\Payment; use WP_Ultimo\Logger; @@ -29,8 +30,10 @@ class Payment_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; + const LOG_FILE_NAME = 'payments'; /** * The manager slug. * @@ -59,6 +62,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + $this->register_forms(); add_action( @@ -317,7 +322,7 @@ public function invoice_viewer(): void { * * @param int $payment_id The ID of the payment being transferred. * @param int $target_customer_id The new owner. - * @return mixed + * @return void */ public function async_transfer_payment($payment_id, $target_customer_id) { @@ -328,7 +333,8 @@ public function async_transfer_payment($payment_id, $target_customer_id) { $target_customer = wu_get_customer($target_customer_id); if ( ! $payment || ! $target_customer || $payment->get_customer_id() === $target_customer->get_id()) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -344,18 +350,17 @@ public function async_transfer_payment($payment_id, $target_customer_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); - return new \WP_Error('exception', $e->getMessage()); + return; } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } /** @@ -364,7 +369,7 @@ public function async_transfer_payment($payment_id, $target_customer_id) { * @since 2.0.0 * * @param int $payment_id The ID of the payment being deleted. - * @return mixed + * @return void */ public function async_delete_payment($payment_id) { @@ -373,7 +378,8 @@ public function async_delete_payment($payment_id) { $payment = wu_get_payment($payment_id); if ( ! $payment) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -387,18 +393,17 @@ public function async_delete_payment($payment_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return new \WP_Error('exception', $e->getMessage()); + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); + return; } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } /** diff --git a/inc/managers/class-product-manager.php b/inc/managers/class-product-manager.php index 1a2a4389c..89151c45d 100644 --- a/inc/managers/class-product-manager.php +++ b/inc/managers/class-product-manager.php @@ -26,6 +26,7 @@ class Product_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -55,5 +56,7 @@ public function init(): void { $this->enable_rest_api(); $this->enable_wp_cli(); + + $this->enable_mcp_abilities(); } } diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 5daf07baf..63233b380 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -14,6 +14,7 @@ use WP_Ultimo\Helpers\Screenshot; use WP_Ultimo\Database\Sites\Site_Type; use WP_Ultimo\Database\Memberships\Membership_Status; +use WP_Ultimo\Models\Site; // Exit if accessed directly defined('ABSPATH') || exit; @@ -27,6 +28,7 @@ class Site_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -57,6 +59,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('after_setup_theme', [$this, 'additional_thumbnail_sizes']); add_action('wp_ajax_wu_get_screenshot', [$this, 'get_site_screenshot']); @@ -83,11 +87,11 @@ public function init(): void { add_filter('mucd_string_to_replace', [$this, 'search_and_replace_on_duplication'], 10, 3); - add_filter('wu_site_created', [$this, 'search_and_replace_for_new_site'], 10, 2); + add_action('wu_site_created', [$this, 'search_and_replace_for_new_site'], 10, 2); add_action('wu_handle_bulk_action_form_site_delete-pending', [$this, 'handle_delete_pending_sites'], 100, 3); - add_action('users_list_table_query_args', [$this, 'hide_super_admin_from_list'], 10, 1); + add_filter('users_list_table_query_args', [$this, 'hide_super_admin_from_list'], 10, 1); add_action('wu_before_handle_order_submission', [$this, 'maybe_validate_add_new_site'], 15); @@ -325,14 +329,14 @@ public function lock_site(): void { * @since 2.0.0 * * @param int $site_id The site ID. - * @return mixed + * @return void */ public function async_get_site_screenshot($site_id) { $site = wu_get_site($site_id); if ( ! $site) { - return false; + return; } $domain = $site->get_active_site_url(); @@ -340,12 +344,12 @@ public function async_get_site_screenshot($site_id) { $attachment_id = Screenshot::take_screenshot($domain); if ( ! $attachment_id) { - return false; + return; } $site->set_featured_image_id($attachment_id); - return $site->save(); + $site->save(); } /** @@ -598,8 +602,8 @@ public function get_search_and_replace_settings() { * Handles search and replace for new blogs from WordPress. * * @since 1.7.0 - * @param array $data The date being saved. - * @param object $site The site object. + * @param array $data The date being saved. + * @param Site $site The site object. * @return void */ public static function search_and_replace_for_new_site($data, $site): void { diff --git a/inc/managers/class-webhook-manager.php b/inc/managers/class-webhook-manager.php index eff77f94d..492eff177 100644 --- a/inc/managers/class-webhook-manager.php +++ b/inc/managers/class-webhook-manager.php @@ -26,6 +26,7 @@ class Webhook_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -72,6 +73,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('init', [$this, 'register_webhook_listeners']); add_action('wp_ajax_wu_send_test_event', [$this, 'send_test_event']); diff --git a/inc/models/class-domain.php b/inc/models/class-domain.php index f3a1bc15c..7465af4cf 100644 --- a/inc/models/class-domain.php +++ b/inc/models/class-domain.php @@ -236,14 +236,12 @@ public function get_site() { */ public function get_path() { if (! isset($this->path)) { - $site = $this->get_site(); + // don't use $this->get_site() as it causes infinite loop and native is faster anyway. + $site = \WP_Site::get_instance($this->get_blog_id()); if ( ! $site) { return null; - } elseif ($site instanceof \WP_Site) { - $this->path = $site->path; - } elseif ($site instanceof Site) { - $this->path = $site->get_path(); } + $this->path = $site->path; } return $this->path; diff --git a/inc/models/class-event.php b/inc/models/class-event.php index 3ef5ac302..01b938416 100644 --- a/inc/models/class-event.php +++ b/inc/models/class-event.php @@ -10,6 +10,7 @@ namespace WP_Ultimo\Models; use WP_Ultimo\Models\Base_Model; +use WP_User; // Exit if accessed directly defined('ABSPATH') || exit; @@ -268,7 +269,7 @@ public function get_message() { */ public function interpolate_message($message, $payload): string { - $payload = json_decode(json_encode($payload), true); + $payload = json_decode(json_encode($payload), true); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode $interpolation_keys = []; @@ -351,7 +352,7 @@ public function get_author_id() { * Returns the user associated with this author. * * @since 2.0.0 - * @return WP_User + * @return ?WP_User */ public function get_author_user() { @@ -377,6 +378,7 @@ public function get_author_display_name() { if ($user) { return $user->display_name; } + return ''; } /** @@ -392,6 +394,7 @@ public function get_author_email_address() { if ($user) { return $user->user_email; } + return ''; } /** @@ -623,8 +626,8 @@ public function to_array() { /** * Override to clear event count. * + * @return bool|\WP_Error * @since 2.0.0 - * @return int|false */ public function save() { diff --git a/inc/models/class-site.php b/inc/models/class-site.php index e53d4467d..c424bd3d3 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1606,10 +1606,9 @@ public function save() { * @since 2.0.0 * * @param array $data The object data that will be stored. - * @param \WP_Ultimo\Models\Base_Model $this The object instance. + * @param \WP_Ultimo\Models\Base_Model $site The object instance. */ - do_action('wu_site_created', $data, $this); // @phpstan-ignore-line - + do_action('wu_site_created', $data, $this); } if ( ! is_wp_error($saved) && wu_get_setting('enable_screenshot_generator', true)) { diff --git a/inc/sso/class-admin-bar-magic-links.php b/inc/sso/class-admin-bar-magic-links.php new file mode 100644 index 000000000..598c311d1 --- /dev/null +++ b/inc/sso/class-admin-bar-magic-links.php @@ -0,0 +1,149 @@ +get_nodes() as $node) { + $parts = explode('-', $node->id); + if (count($parts) >= 3 && 'blog' === $parts[0] && is_numeric($parts[1]) && 'd' === $parts[2]) { + $site_id = (int) $parts[1]; + } else { + continue; + } + + // Generate magic link. + $magic_link = wu_get_admin_url($site_id); + + if ( ! $magic_link ) { + continue; + } + + // Update the node with the magic link. + $node->href = $magic_link; + + $wp_admin_bar->add_node($node); + } + } + + /** + * Show access denied splash screen with magic links. + * + * This replaces the WordPress core access denied splash screen + * with our own version that uses magic links for sites with custom domains. + * + * @since 2.0.0 + * @return void + */ + public function show_access_denied_with_magic_links(): void { + + // Only run in multisite and if user is logged in. + if ( ! is_multisite() || ! is_user_logged_in() || is_network_admin() ) { + return; + } + + $blogs = get_blogs_of_user(get_current_user_id()); + + // If user has blogs and current blog is not in their list, show our custom message. + if ( wp_list_filter($blogs, array('userblog_id' => get_current_blog_id())) ) { + return; + } + + $blog_name = get_bloginfo('name'); + + if ( empty($blogs) ) { + wp_die( + sprintf( + /* translators: 1: Site title. */ + __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.'), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $blog_name // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ), + 403 + ); + } + + $output = '

' . sprintf( + /* translators: 1: Site title. */ + __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.'), + $blog_name + ) . '

'; + $output .= '

' . __('If you reached this screen by accident and meant to visit one of your own sites, here are some shortcuts to help you find your way.') . '

'; + + $output .= '

' . __('Your Sites') . '

'; + $output .= ''; + + foreach ( $blogs as $blog ) { + $site_id = (int) $blog->userblog_id; + + // Get dashboard URL (with magic link if needed). + $dashboard_url = wu_get_admin_url($site_id); + + // Get home URL (with magic link if needed). + $home_url = wu_get_home_url($site_id); + + $output .= ''; + $output .= ''; + $output .= ''; + $output .= ''; + } + + $output .= '
' . esc_html($blog->blogname) . '' . __('Visit Dashboard') . ' | ' . + '' . __('View Site') . '
'; + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is escaped above with esc_url() and esc_html(). + wp_die($output, 403); + } +} diff --git a/inc/sso/class-magic-link.php b/inc/sso/class-magic-link.php new file mode 100644 index 000000000..231a1c41a --- /dev/null +++ b/inc/sso/class-magic-link.php @@ -0,0 +1,593 @@ +is_enabled() ) { + return false; + } + + // Verify user exists and has access to the site. + if ( ! $this->verify_user_site_access($user_id, $site_id) ) { + return false; + } + + // Generate secure token. + $token = $this->generate_token(); + + // Get user agent and IP for additional security. + $user_agent = $this->get_user_agent(); + $ip_address = $this->get_client_ip(); + + // Store token data with security context. + $token_data = array( + 'user_id' => $user_id, + 'site_id' => $site_id, + 'redirect_to' => $redirect_to, + 'created_at' => time(), + 'user_agent' => $user_agent, + 'ip_address' => $ip_address, + ); + + $transient_key = self::TRANSIENT_PREFIX . $token; + + wu_switch_blog_and_run( + fn() => set_transient($transient_key, $token_data, self::TOKEN_EXPIRATION) + ); + + $site = wu_get_site($site_id); + + // Build the magic link URL. + $site_url = $site->get_active_site_url(); + + $magic_link = add_query_arg( + array( + self::TOKEN_QUERY_VAR => $token, + ), + $site_url + ); + + /** + * Filter the generated magic link URL. + * + * @since 2.0.0 + * + * @param string $magic_link The magic link URL. + * @param int $user_id The user ID. + * @param int $site_id The site ID. + * @param string $redirect_to The redirect URL. + */ + return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to); + } + + /** + * Generate a cryptographically secure token. + * + * @since 2.0.0 + * @return string The generated token. + */ + protected function generate_token() { + + return bin2hex(random_bytes(32)); + } + + /** + * Verify that a user has access to a specific site. + * + * @since 2.0.0 + * + * @param int $user_id The user ID. + * @param int $site_id The site ID. + * @return bool True if user has access, false otherwise. + */ + protected function verify_user_site_access($user_id, $site_id) { + + $user = get_userdata($user_id); + + if ( ! $user ) { + return false; + } + + // Check if user is a member of the site. + return is_user_member_of_blog($user_id, $site_id); + } + + /** + * Handle magic link token verification and login. + * + * @since 2.0.0 + * @return void + */ + public function handle_magic_link(): void { + + $token = wu_request(self::TOKEN_QUERY_VAR); + + if ( empty($token) ) { + return; + } + + // Verify and consume the token. + $token_data = $this->verify_and_consume_token($token); + + if ( false === $token_data ) { + // Token is invalid, expired, or already used. + $this->handle_invalid_token(); + return; + } + + // Extract token data. + $user_id = $token_data['user_id']; + $site_id = $token_data['site_id']; + $redirect_to = $token_data['redirect_to']; + + // Verify we're on the correct site. + if ( get_current_blog_id() !== $site_id ) { + $this->handle_invalid_token('Wrong site for this token.'); + return; + } + + // Verify user still has access to the site. + if ( ! $this->verify_user_site_access($user_id, $site_id) ) { + $this->handle_invalid_token('User does not have access to this site.'); + return; + } + + // Log the user in. + wp_set_auth_cookie($user_id, true); + + /** + * Fires after a user is logged in via magic link. + * + * @since 2.0.0 + * + * @param int $user_id The user ID. + * @param int $site_id The site ID. + */ + do_action('wu_magic_link_login', $user_id, $site_id); + + if ( empty($redirect_to) ) { + return; + } + + // Remove the token from the URL and redirect. + $redirect_to = remove_query_arg(self::TOKEN_QUERY_VAR, $redirect_to); + + nocache_headers(); + + wp_safe_redirect($redirect_to); + + exit; + } + + /** + * Verify a token and mark it as used. + * + * @since 2.0.0 + * + * @param string $token The token to verify. + * @return array|false Token data on success, false on failure. + */ + protected function verify_and_consume_token($token) { + + $transient_key = self::TRANSIENT_PREFIX . $token; + + $token_data = wu_switch_blog_and_run( + fn() => get_transient($transient_key) + ); + + if ( false === $token_data ) { + wu_log_add('magic-link', sprintf('Token not found or expired: %s', $token)); + return false; + } + + // Verify security context (user agent and IP). + if ( ! $this->verify_security_context($token_data) ) { + wu_log_add('magic-link', sprintf('Security context mismatch for token: %s', $token)); + wu_switch_blog_and_run( + fn() => delete_transient($transient_key) + ); + return false; + } + + // Delete the transient to ensure one-time use. + wu_switch_blog_and_run( + fn() => delete_transient($transient_key) + ); + + // Log successful authentication for audit trail. + wu_log_add( + 'magic-link', + sprintf( + 'Successful magic link login for user %d to site %d from IP %s', + $token_data['user_id'], + $token_data['site_id'], + $this->get_client_ip() + ) + ); + + return $token_data; + } + + /** + * Handle invalid token scenario. + * + * @since 2.0.0 + * + * @param string $reason Optional. Reason for invalid token. + * @return void + */ + protected function handle_invalid_token($reason = '') { + + if ( $reason ) { + wu_log_add('magic-link', sprintf('Invalid token: %s', $reason)); + } + + /** + * Fires when an invalid magic link token is encountered. + * + * @since 2.0.0 + * + * @param string $reason The reason for the invalid token. + */ + do_action('wu_magic_link_invalid_token', $reason); + } + + /** + * Check if a site has a custom domain different from the main site. + * + * @since 2.0.0 + * + * @param int $site_id The site ID to check. + * @return bool True if site has a custom domain, false otherwise. + */ + public function site_needs_magic_link($site_id) { + + $site = wu_get_site($site_id); + + if ( ! $site ) { + return false; + } + + // Check if site has a primary mapped domain. + $primary_domain = $site->get_primary_mapped_domain(); + + if ( ! $primary_domain ) { + return false; + } + + // Get the main site domain. + $main_site_domain = wp_parse_url(get_site_url(wu_get_main_site_id()), PHP_URL_HOST); + + // Get the custom domain. + $custom_domain = $primary_domain->get_domain(); + + // If not a subdomain we need a magic link + return ! str_ends_with($custom_domain, $main_site_domain); + } + + /** + * Check if magic links are enabled. + * + * @since 2.0.0 + * @return bool True if enabled, false otherwise. + */ + protected function is_enabled() { + + /** + * Filter whether magic links are enabled. + * + * @since 2.0.0 + * + * @param bool $enabled Whether magic links are enabled. + */ + $enabled = apply_filters('wu_magic_links_enabled', wu_get_setting('enable_magic_links', true)); + + return (bool) $enabled; + } + + /** + * Verify security context (user agent and IP address). + * + * @since 2.0.0 + * + * @param array $token_data Token data containing security context. + * @return bool True if security context matches, false otherwise. + */ + protected function verify_security_context($token_data) { + + // Get current user agent and IP. + $current_user_agent = $this->get_user_agent(); + $current_ip = $this->get_client_ip(); + + // Get stored values. + $stored_user_agent = $token_data['user_agent'] ?? ''; + $stored_ip = $token_data['ip_address'] ?? ''; + + /** + * Filter whether to enforce user agent verification. + * + * Set to false to allow tokens to work across different browsers/devices. + * This reduces security but increases usability. + * + * @since 2.0.0 + * + * @param bool $enforce Whether to enforce user agent matching. + */ + $enforce_user_agent = apply_filters('wu_magic_link_enforce_user_agent', true); + + /** + * Filter whether to enforce IP address verification. + * + * Set to false to allow tokens to work from different networks. + * This reduces security but increases usability (e.g., for mobile users switching networks). + * + * @since 2.0.0 + * + * @param bool $enforce Whether to enforce IP address matching. + */ + $enforce_ip = apply_filters('wu_magic_link_enforce_ip', false); + + // Verify user agent if enforced. + if ( $enforce_user_agent && $stored_user_agent !== $current_user_agent ) { + wu_log_add( + 'magic-link', + sprintf( + 'User agent mismatch. Expected: %s, Got: %s', + $stored_user_agent, + $current_user_agent + ) + ); + return false; + } + + // Verify IP address if enforced. + if ( $enforce_ip && $stored_ip !== $current_ip ) { + wu_log_add( + 'magic-link', + sprintf( + 'IP address mismatch. Expected: %s, Got: %s', + $stored_ip, + $current_ip + ) + ); + return false; + } + + return true; + } + + /** + * Get the current user's user agent. + * + * @since 2.0.0 + * @return string The user agent string. + */ + protected function get_user_agent() { + + return isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; + } + + /** + * Get the client's IP address. + * + * Checks for proxies and load balancers. + * + * @since 2.0.0 + * @return string The client IP address. + */ + protected function get_client_ip() { + + $ip = ''; + + // Check for proxied IP addresses. + $headers = array( + 'HTTP_CF_CONNECTING_IP', // Cloudflare. + 'HTTP_X_FORWARDED_FOR', // Common proxy header. + 'HTTP_X_REAL_IP', // Nginx proxy. + 'REMOTE_ADDR', // Direct connection. + ); + + foreach ( $headers as $header ) { + if ( ! empty($_SERVER[ $header ]) ) { + $ip = sanitize_text_field(wp_unslash($_SERVER[ $header ])); + + // X-Forwarded-For may contain multiple IPs, take the first one. + if ( strpos($ip, ',') !== false ) { + $ip_list = explode(',', $ip); + $ip = trim($ip_list[0]); + } + + // Validate IP address. + if ( filter_var($ip, FILTER_VALIDATE_IP) ) { + break; + } + } + } + + return $ip; + } + + /** + * Maybe convert a URL to a magic link. + * + * This method is hooked into the wp_frontend_admin/my_site_url filter + * to convert URLs to magic links when accessing sites with custom domains. + * + * @since 2.0.0 + * + * @param string $url The URL to potentially convert. + * @return string The magic link URL or original URL. + */ + public function maybe_convert_to_magic_link($url) { + + // If not enabled, return original URL. + if ( ! $this->is_enabled() ) { + return $url; + } + + // Get current user ID. + $current_user_id = get_current_user_id(); + + if ( ! $current_user_id ) { + return $url; + } + + // Try to extract site ID from URL if not provided. + $site_id = $this->extract_site_id_from_url($url); + + if ( ! $site_id ) { + return $url; + } + + // Check if this site needs a magic link. + if ( ! $this->site_needs_magic_link($site_id) ) { + return $url; + } + + // Generate magic link with the original URL as redirect target. + $magic_link = $this->generate_magic_link($current_user_id, $site_id, $url); + + return $magic_link ?: $url; + } + + /** + * Extract site ID from a URL. + * + * Attempts to determine which site a URL belongs to by parsing the domain. + * + * @since 2.0.0 + * + * @param string $url The URL to parse. + * @return int|null The site ID or null if not found. + */ + protected function extract_site_id_from_url($url) { + + $parsed_url = wp_parse_url($url); + + if ( ! isset($parsed_url['host']) ) { + return null; + } + + $host = $parsed_url['host']; + + // Try to find a domain mapping for this host. + $domain = wu_get_domain_by_domain($host); + + if ( $domain ) { + return $domain->get_blog_id(); + } + + // Try to get site by domain (for subdomain/subdirectory installs). + $site = get_site_by_path($host, isset($parsed_url['path']) ? $parsed_url['path'] : '/'); + + if ( $site ) { + return $site->blog_id; + } + + return null; + } +} diff --git a/inc/sso/class-sso.php b/inc/sso/class-sso.php index 26fa16208..c4ad08b3f 100644 --- a/inc/sso/class-sso.php +++ b/inc/sso/class-sso.php @@ -605,8 +605,8 @@ public function add_additional_origins($allowed_origins) { ); foreach ($domains as $domain) { - $additional_domains[] = "http://{$domain->get_domain()}"; - $additional_domains[] = "https://{$domain->get_domain()}"; + $additional_domains[] = "http://{$domain}"; + $additional_domains[] = "https://{$domain}"; } } diff --git a/inc/ui/class-my-sites-element.php b/inc/ui/class-my-sites-element.php index 29f6d8bf1..ddb15310b 100644 --- a/inc/ui/class-my-sites-element.php +++ b/inc/ui/class-my-sites-element.php @@ -402,7 +402,7 @@ function ($user_sites, $wp_site) use ($customer_sites) { public function get_manage_url($site_id, $type = 'default', $custom_page_id = 0) { if ('wp_admin' === $type) { - return get_admin_url($site_id); + return wu_get_admin_url($site_id); } if ('custom_page' === $type) { diff --git a/tests/WP_Ultimo/Models/Event_Test.php b/tests/WP_Ultimo/Models/Event_Test.php index 5b1f14076..d2e31c47b 100644 --- a/tests/WP_Ultimo/Models/Event_Test.php +++ b/tests/WP_Ultimo/Models/Event_Test.php @@ -316,8 +316,8 @@ public function test_author_methods_with_no_user() { $this->event->set_author_id(0); $this->assertNull($this->event->get_author_user()); - $this->assertNull($this->event->get_author_display_name()); - $this->assertNull($this->event->get_author_email_address()); + $this->assertEmpty($this->event->get_author_display_name()); + $this->assertEmpty($this->event->get_author_email_address()); } /**