diff --git a/inc/apis/class-settings-endpoint.php b/inc/apis/class-settings-endpoint.php new file mode 100644 index 00000000..2ab6476c --- /dev/null +++ b/inc/apis/class-settings-endpoint.php @@ -0,0 +1,439 @@ +get_namespace(); + + // GET /settings - Retrieve all settings + register_rest_route( + $namespace, + '/settings', + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'get_settings'], + 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']), + ] + ); + + // GET /settings/{setting_key} - Retrieve a specific setting + register_rest_route( + $namespace, + '/settings/(?P[a-zA-Z0-9_-]+)', + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'get_setting'], + 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']), + 'args' => [ + 'setting_key' => [ + 'description' => __('The setting key to retrieve.', 'ultimate-multisite'), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ], + ], + ] + ); + + // POST /settings - Update multiple settings + register_rest_route( + $namespace, + '/settings', + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [$this, 'update_settings'], + 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']), + 'args' => $this->get_update_args(), + ] + ); + + // PUT/PATCH /settings/{setting_key} - Update a specific setting + register_rest_route( + $namespace, + '/settings/(?P[a-zA-Z0-9_-]+)', + [ + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => [$this, 'update_setting'], + 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']), + 'args' => [ + 'setting_key' => [ + 'description' => __('The setting key to update.', 'ultimate-multisite'), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ], + 'value' => [ + 'description' => __('The new value for the setting.', 'ultimate-multisite'), + 'required' => true, + ], + ], + ] + ); + } + + /** + * Get all settings. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response + */ + public function get_settings($request) { + + $this->maybe_log_api_call($request); + + $settings = wu_get_all_settings(); + + // Remove sensitive settings from the response + $settings = $this->filter_sensitive_settings($settings); + + return rest_ensure_response( + [ + 'success' => true, + 'settings' => $settings, + ] + ); + } + + /** + * Get a specific setting. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response|\WP_Error + */ + public function get_setting($request) { + + $this->maybe_log_api_call($request); + + $setting_key = $request->get_param('setting_key'); + + // Check if this is a sensitive setting + if ($this->is_sensitive_setting($setting_key)) { + return new \WP_Error( + 'setting_protected', + __('This setting is protected and cannot be retrieved via the API.', 'ultimate-multisite'), + ['status' => 403] + ); + } + + $value = wu_get_setting($setting_key, null); + + if (null === $value) { + // Check if setting exists (even with null/false value) vs doesn't exist + $all_settings = wu_get_all_settings(); + + if (! array_key_exists($setting_key, $all_settings)) { + return new \WP_Error( + 'setting_not_found', + sprintf( + /* translators: %s is the setting key */ + __('Setting "%s" not found.', 'ultimate-multisite'), + $setting_key + ), + ['status' => 404] + ); + } + } + + return rest_ensure_response( + [ + 'success' => true, + 'setting_key' => $setting_key, + 'value' => $value, + ] + ); + } + + /** + * Update multiple settings. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response|\WP_Error + */ + public function update_settings($request) { + + $this->maybe_log_api_call($request); + + $params = $request->get_json_params(); + + if (empty($params) || ! is_array($params)) { + $params = $request->get_body_params(); + } + + $settings_to_update = wu_get_isset($params, 'settings', $params); + + if (empty($settings_to_update) || ! is_array($settings_to_update)) { + return new \WP_Error( + 'invalid_settings', + __('No valid settings provided. Please provide a "settings" object with key-value pairs.', 'ultimate-multisite'), + ['status' => 400] + ); + } + + // Validate and filter out sensitive settings + $errors = []; + $filtered_settings = []; + + foreach ($settings_to_update as $key => $value) { + if ($this->is_sensitive_setting($key)) { + $errors[] = sprintf( + /* translators: %s is the setting key */ + __('Setting "%s" is protected and cannot be modified via the API.', 'ultimate-multisite'), + $key + ); + continue; + } + + // Validate setting key format + $sanitized_key = sanitize_key($key); + if ($sanitized_key !== $key) { + $errors[] = sprintf( + /* translators: %s is the setting key */ + __('Invalid setting key format: "%s".', 'ultimate-multisite'), + $key + ); + continue; + } + + $filtered_settings[ $key ] = $value; + } + + if (empty($filtered_settings)) { + return new \WP_Error( + 'no_valid_settings', + __('No valid settings to update after filtering.', 'ultimate-multisite'), + [ + 'status' => 400, + 'errors' => $errors, + ] + ); + } + + // Save each setting + $updated = []; + $failed = []; + + foreach ($filtered_settings as $key => $value) { + $result = wu_save_setting($key, $value); + + if ($result) { + $updated[] = $key; + } else { + $failed[] = $key; + } + } + + $response_data = [ + 'success' => ! empty($updated), + 'updated' => $updated, + ]; + + if (! empty($failed)) { + $response_data['failed'] = $failed; + } + + if (! empty($errors)) { + $response_data['warnings'] = $errors; + } + + return rest_ensure_response($response_data); + } + + /** + * Update a specific setting. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response|\WP_Error + */ + public function update_setting($request) { + + $this->maybe_log_api_call($request); + + $setting_key = $request->get_param('setting_key'); + + // Check if this is a sensitive setting + if ($this->is_sensitive_setting($setting_key)) { + return new \WP_Error( + 'setting_protected', + __('This setting is protected and cannot be modified via the API.', 'ultimate-multisite'), + ['status' => 403] + ); + } + + $params = $request->get_json_params(); + + if (empty($params)) { + $params = $request->get_body_params(); + } + + $value = wu_get_isset($params, 'value'); + + if (! isset($params['value'])) { + return new \WP_Error( + 'missing_value', + __('The "value" parameter is required.', 'ultimate-multisite'), + ['status' => 400] + ); + } + + $result = wu_save_setting($setting_key, $value); + + if (! $result) { + return new \WP_Error( + 'update_failed', + sprintf( + /* translators: %s is the setting key */ + __('Failed to update setting "%s".', 'ultimate-multisite'), + $setting_key + ), + ['status' => 500] + ); + } + + return rest_ensure_response( + [ + 'success' => true, + 'setting_key' => $setting_key, + 'value' => wu_get_setting($setting_key), + ] + ); + } + + /** + * Get the arguments schema for the update endpoint. + * + * @since 2.4.0 + * @return array + */ + protected function get_update_args(): array { + + return [ + 'settings' => [ + 'description' => __('An object containing setting key-value pairs to update.', 'ultimate-multisite'), + 'type' => 'object', + 'required' => false, + ], + ]; + } + + /** + * Check if a setting is sensitive and should not be exposed via API. + * + * @since 2.4.0 + * + * @param string $setting_key The setting key to check. + * @return bool + */ + protected function is_sensitive_setting(string $setting_key): bool { + + $sensitive_settings = [ + 'api_key', + 'api_secret', + 'stripe_api_sk_live', + 'stripe_api_sk_test', + 'paypal_client_secret_live', + 'paypal_client_secret_sandbox', + ]; + + /** + * Filter the list of sensitive settings that should not be exposed via API. + * + * @since 2.4.0 + * + * @param array $sensitive_settings List of sensitive setting keys. + * @param string $setting_key The setting key being checked. + */ + $sensitive_settings = apply_filters('wu_api_sensitive_settings', $sensitive_settings, $setting_key); + + return in_array($setting_key, $sensitive_settings, true); + } + + /** + * Filter out sensitive settings from a settings array. + * + * @since 2.4.0 + * + * @param array $settings The settings array to filter. + * @return array + */ + protected function filter_sensitive_settings(array $settings): array { + + foreach ($settings as $key => $value) { + if ($this->is_sensitive_setting($key)) { + unset($settings[ $key ]); + } + } + + return $settings; + } + + /** + * Log API call if logging is enabled. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request The request object. + * @return void + */ + protected function maybe_log_api_call($request): void { + + if (\WP_Ultimo\API::get_instance()->should_log_api_calls()) { + $payload = [ + 'route' => $request->get_route(), + 'method' => $request->get_method(), + 'url_params' => $request->get_url_params(), + 'body_params' => $request->get_body(), + ]; + + wu_log_add('api-calls', wp_json_encode($payload, JSON_PRETTY_PRINT)); + } + } +} diff --git a/inc/checkout/class-checkout-pages.php b/inc/checkout/class-checkout-pages.php index 66c1e47c..b316e0ed 100644 --- a/inc/checkout/class-checkout-pages.php +++ b/inc/checkout/class-checkout-pages.php @@ -37,19 +37,28 @@ public function init(): void { add_filter('lostpassword_redirect', [$this, 'filter_lost_password_redirect']); + $use_custom_login = wu_get_setting('enable_custom_login_page', false); + + /* + * Login URL filters need to run on ALL sites (including subsites) + * so that password reset and login links redirect to the main site's + * custom login page instead of wp-login.php (which may be obfuscated). + * + * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/291 + */ + if ($use_custom_login) { + add_filter('login_url', [$this, 'filter_login_url'], 10, 3); + + add_filter('lostpassword_url', [$this, 'filter_login_url'], 10, 3); + } + if (is_main_site()) { add_action('before_signup_header', [$this, 'redirect_to_registration_page']); - $use_custom_login = wu_get_setting('enable_custom_login_page', false); - if ( ! $use_custom_login) { return; } - add_filter('login_url', [$this, 'filter_login_url'], 10, 3); - - add_filter('lostpassword_url', [$this, 'filter_login_url'], 10, 3); - add_filter('retrieve_password_message', [$this, 'replace_reset_password_link'], 10, 4); add_filter('network_site_url', [$this, 'maybe_change_wp_login_on_urls']); diff --git a/inc/class-addon-repository.php b/inc/class-addon-repository.php index 8bcb0550..ca02fd5d 100644 --- a/inc/class-addon-repository.php +++ b/inc/class-addon-repository.php @@ -1,7 +1,12 @@ get_error_message()), (int) $request->get_error_code()); + wu_log_add('api-calls', $request->get_error_message(), LogLevel::ERROR); + $this->delete_tokens(); } if (200 === absint($code) && 'OK' === $message) { $user = json_decode($body, true); diff --git a/inc/class-settings.php b/inc/class-settings.php index f6e111cf..be022253 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -708,6 +708,22 @@ public function default_sections(): void { 120 ); + $this->add_field( + 'general', + 'enable_error_reporting', + [ + 'title' => __('Help Improve Ultimate Multisite', 'ultimate-multisite'), + 'desc' => sprintf( + /* translators: %s is a link to the privacy policy */ + __('Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. Learn more.', 'ultimate-multisite'), + 'https://ultimatemultisite.com/privacy-policy/' + ), + 'type' => 'toggle', + 'default' => 0, + ], + 130 + ); + /* * Login & Registration * This section holds the Login & Registration settings of the Ultimate Multisite Plugin. @@ -1730,21 +1746,6 @@ public function default_sections(): void { ] ); - $this->add_field( - 'other', - 'enable_error_reporting', - [ - 'title' => __('Help Improve Ultimate Multisite', 'ultimate-multisite'), - 'desc' => sprintf( - /* translators: %s is a link to the privacy policy */ - __('Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. Learn more.', 'ultimate-multisite'), - 'https://ultimatemultisite.com/privacy-policy/' - ), - 'type' => 'toggle', - 'default' => 0, - ] - ); - $this->add_field( 'other', 'advanced_header', diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 1ff69b9a..029ea638 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -540,6 +540,11 @@ protected function load_extra_components(): void { */ \WP_Ultimo\API\Register_Endpoint::get_instance(); + /* + * Loads API settings endpoint. + */ + \WP_Ultimo\API\Settings_Endpoint::get_instance(); + /* * Loads Documentation */ diff --git a/inc/functions/helper.php b/inc/functions/helper.php index ae1729c0..409686d7 100644 --- a/inc/functions/helper.php +++ b/inc/functions/helper.php @@ -105,7 +105,7 @@ function wu_slugify($term) { */ function wu_path($dir): string { - return WP_ULTIMO_PLUGIN_DIR . $dir; // @phpstan-ignore-line + return WP_ULTIMO_PLUGIN_DIR . $dir; } /** @@ -117,7 +117,7 @@ function wu_path($dir): string { */ function wu_url($dir) { - return apply_filters('wp_ultimo_url', WP_ULTIMO_PLUGIN_URL . $dir); // @phpstan-ignore-line + return apply_filters('wp_ultimo_url', WP_ULTIMO_PLUGIN_URL . $dir); } /** @@ -294,8 +294,7 @@ function wu_ignore_errors($func, $log = false) { // phpcs:ignore Generic.CodeAna try { call_user_func($func); - } catch (\Throwable $exception) { - + } catch (\Throwable $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Ignore it or log it. } } @@ -496,7 +495,7 @@ function wu_kses_allowed_html(): array { 'template' => true, ]; - return [ + $allowed_tags = [ 'svg' => $svg_attributes + [ 'width' => true, 'height' => true, @@ -613,4 +612,15 @@ function wu_kses_allowed_html(): array { 'height' => true, ], ] + array_merge_recursive($allowed_html, array_fill_keys(array_keys($allowed_html) + ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input', 'textarea'], $vue_and_data_attributes)); + + /** + * Filters the allowed HTML tags and attributes. + * + * Allows addons to extend the allowed HTML elements for wp_kses sanitization. + * + * @since 2.5.0 + * + * @param array $allowed_tags The allowed HTML tags and attributes. + */ + return apply_filters('wu_kses_allowed_html', $allowed_tags); } diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index 1374379b..137b5e29 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -771,12 +771,18 @@ public function output($atts, $content = null): void { 'title' => $atts['label_username'], 'placeholder' => $atts['placeholder_username'], 'tooltip' => '', + 'html_attr' => [ + 'autocomplete' => 'username', + ], ], 'pwd' => [ 'type' => 'password', 'title' => $atts['label_password'], 'placeholder' => $atts['placeholder_password'], 'tooltip' => '', + 'html_attr' => [ + 'autocomplete' => 'current-password', + ], ], ]; diff --git a/readme.txt b/readme.txt index 1f72355c..86c53f96 100644 --- a/readme.txt +++ b/readme.txt @@ -240,16 +240,23 @@ We recommend running this in a staging environment before updating your producti == Changelog == +Version [2.4.10] - Released on 2026-XX-XX +- New: Settings API +- Fix: Problems with choosing country and state + + Version [2.4.10] - Released on 2026-01-23 - New: Configurable minimum password strength setting with Medium, Strong, and Super Strong options. - New: Super Strong password requirements include 12+ characters, uppercase, lowercase, numbers, and special characters - compatible with WPMU DEV Defender Pro rules. - New: Real-time password requirement hints during checkout with translatable strings. - New: Themed password field styling with visibility toggle and color fallbacks for page builders (Elementor, Kadence, Beaver Builder). - New: Opt-in anonymous usage tracking to help improve the plugin. +- New: Better error page for customers and admins. - New: Rating reminder notice after 30 days of installation. - New: WooCommerce Subscriptions compatibility layer for site duplication. - Improved: JSON response handling for pending site creation in non-FastCGI environments. + Version [2.4.9] - Released on 2025-12-23 - New: Inline login prompt at checkout for existing users - returning customers can sign in directly without leaving the checkout flow. - New: GitHub Actions workflow for PR builds with WordPress Playground testing - enables one-click browser-based testing of pull requests. diff --git a/views/checkout/fields/field-select.php b/views/checkout/fields/field-select.php index 2f0d0ca1..2008bb17 100644 --- a/views/checkout/fields/field-select.php +++ b/views/checkout/fields/field-select.php @@ -26,10 +26,16 @@ ?> + html_attr['v-bind:name']); + ?> print_html_attributes(); ?>> + html_attr['v-bind:name']); + ?> + + name="id); ?>" type="type); ?>" placeholder="placeholder); ?>" value="value); ?>" print_html_attributes(); ?>> suffix) : ?>