Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 53 additions & 16 deletions assets/js/src/components/admin/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const Settings = () => {
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState(null);
const [externalSources, setExternalSources] = useState([]);
Expand Down Expand Up @@ -338,6 +339,30 @@ const Settings = () => {
}
};

const handleTestConnection = async () => {
try {
setIsTesting(true);
setError(null);
setSuccessMessage(null);

if (!isValidHttpsUrl(settings.bmlt_root_server)) {
throw new Error(__('BMLT Root Server URL must use HTTPS protocol.', 'mayo-events-manager'));
}

await apiFetch('/validate-root-server', {
method: 'POST',
body: JSON.stringify({ bmlt_root_server: settings.bmlt_root_server })
});

setSuccessMessage(__('Successfully connected to the BMLT root server.', 'mayo-events-manager'));
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
setError(err.message || __('Could not reach the BMLT root server.', 'mayo-events-manager'));
} finally {
setIsTesting(false);
}
};

if (isLoading) {
return (
<div className="mayo-settings-loading">
Expand Down Expand Up @@ -381,22 +406,34 @@ const Settings = () => {
<Panel>
<PanelBody title={__('BMLT Settings', 'mayo-events-manager')} initialOpen={true}>
<PanelRow>
<TextControl
label={__('BMLT Root Server URL', 'mayo-events-manager')}
value={settings.bmlt_root_server}
onChange={(value) => handleChange('bmlt_root_server', value)}
help={
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
? __("URL must start with 'https://'", 'mayo-events-manager')
: __('Enter the URL of your BMLT root server (e.g., https://bmlt.example.org/main_server)', 'mayo-events-manager')
}
className={
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
? 'mayo-invalid-url'
: ''
}
__next40pxDefaultSize={true}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%' }}>
<div style={{ flex: 1 }}>
<TextControl
label={__('BMLT Root Server URL', 'mayo-events-manager')}
value={settings.bmlt_root_server}
onChange={(value) => handleChange('bmlt_root_server', value)}
help={
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
? __("URL must start with 'https://'", 'mayo-events-manager')
: __('Enter the URL of your BMLT root server (e.g., https://bmlt.example.org/main_server)', 'mayo-events-manager')
}
className={
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
? 'mayo-invalid-url'
: ''
}
__next40pxDefaultSize={true}
/>
</div>
<Button
isSecondary
onClick={handleTestConnection}
isBusy={isTesting}
disabled={isTesting || !isValidHttpsUrl(settings.bmlt_root_server)}
>
{isTesting ? __('Testing…', 'mayo-events-manager') : __('Test connection', 'mayo-events-manager')}
</Button>
</div>
</PanelRow>

<PanelRow>
Expand Down
13 changes: 12 additions & 1 deletion assets/js/src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,18 @@ export const apiFetch = async (endpoint, options = {}) => {
const response = await fetch(url, fetchOptions);

if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
// Prefer the REST API error message (e.g. WP_Error) when present
// so callers can show a meaningful reason instead of a bare status.
let message = `API error: ${response.status} ${response.statusText}`;
try {
const errorBody = await response.json();
if (errorBody && errorBody.message) {
message = errorBody.message;
}
} catch (e) {
// Response body wasn't JSON; keep the status-based message.
}
throw new Error(message);
}

return await response.json();
Expand Down
141 changes: 141 additions & 0 deletions includes/Rest/Helpers/RootServerValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

namespace BmltEnabled\Mayo\Rest\Helpers;

if ( ! defined( 'ABSPATH' ) ) exit;

/**
* Validates that a BMLT root server URL is well-formed and reachable.
*
* Performs cheap checks first (format, scheme) before an outbound handshake
* against the BMLT semantic endpoint to confirm the URL actually points at a
* BMLT root server.
*/
class RootServerValidator {

/**
* Timeout, in seconds, for the outbound reachability handshake.
*
* Kept shorter than the read-path lookup in ServiceBodyLookup (15s) so a
* slow host can't hang a settings save for long.
*/
const TIMEOUT = 10;

/**
* Validate and normalize a BMLT root server URL.
*
* @param string $url The candidate root server URL.
* @return string|\WP_Error Normalized URL (no trailing slash) on success,
* or a WP_Error describing why validation failed.
*/
public static function validate( $url ) {
$url = is_string( $url ) ? trim( $url ) : '';

if ( $url === '' ) {
return new \WP_Error(
'invalid_root_server_url',
__( 'Please enter a BMLT root server URL.', 'mayo-events-manager' ),
[ 'status' => 400 ]
);
}

// 1. Format — must be a valid absolute URL.
$normalized = untrailingslashit( esc_url_raw( $url ) );

if ( empty( $normalized ) || ! wp_http_validate_url( $normalized ) ) {
return new \WP_Error(
'invalid_root_server_url',
sprintf(
/* translators: %s: the URL the user entered. */
__( '"%s" is not a valid URL.', 'mayo-events-manager' ),
$url
),
[ 'status' => 400 ]
);
}

// 2. Scheme — require https.
$scheme = strtolower( (string) wp_parse_url( $normalized, PHP_URL_SCHEME ) );
if ( $scheme !== 'https' ) {
return new \WP_Error(
'invalid_root_server_url',
sprintf(
/* translators: %s: the URL the user entered. */
__( 'The BMLT root server URL must start with "https://" (got "%s").', 'mayo-events-manager' ),
$url
),
[ 'status' => 400 ]
);
}

// 3. Reachability — handshake against the BMLT semantic endpoint.
$response = wp_remote_get(
$normalized . '/client_interface/json/?switcher=GetServerInfo',
[
'timeout' => self::TIMEOUT,
'sslverify' => true,
]
);

if ( is_wp_error( $response ) ) {
return new \WP_Error(
'root_server_unreachable',
sprintf(
/* translators: %s: the BMLT root server URL. */
__( 'Could not reach a BMLT root server at %s', 'mayo-events-manager' ),
$normalized
),
[ 'status' => 400 ]
);
}

$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code !== 200 ) {
return new \WP_Error(
'root_server_unreachable',
sprintf(
/* translators: 1: the BMLT root server URL, 2: the HTTP status code returned. */
__( 'Could not reach a BMLT root server at %1$s (HTTP %2$d).', 'mayo-events-manager' ),
$normalized,
$code
),
[ 'status' => 400 ]
);
}

$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! self::looks_like_server_info( $data ) ) {
return new \WP_Error(
'not_a_bmlt_root_server',
sprintf(
/* translators: %s: the BMLT root server URL. */
__( 'The URL %s did not respond like a BMLT root server.', 'mayo-events-manager' ),
$normalized
),
[ 'status' => 400 ]
);
}

return $normalized;
}

/**
* Determine whether a decoded GetServerInfo response looks like a BMLT
* root server. GetServerInfo returns a single-element list of objects
* carrying a `version` field, e.g. [ { "version": "3.0.0", ... } ].
*
* @param mixed $data Decoded JSON response body.
* @return bool
*/
private static function looks_like_server_info( $data ) {
if ( ! is_array( $data ) ) {
return false;
}

if ( isset( $data['version'] ) ) {
return true;
}

return isset( $data[0] ) && is_array( $data[0] ) && isset( $data[0]['version'] );
}
}
60 changes: 58 additions & 2 deletions includes/Rest/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace BmltEnabled\Mayo\Rest;

use BmltEnabled\Mayo\Rest\Helpers\RootServerValidator;

if ( ! defined( 'ABSPATH' ) ) exit;

/**
Expand All @@ -26,6 +28,14 @@ public static function register_routes() {
return current_user_can('manage_options');
}
]);

register_rest_route('event-manager/v1', '/validate-root-server', [
'methods' => 'POST',
'callback' => [__CLASS__, 'validate_root_server'],
'permission_callback' => function() {
return current_user_can('manage_options');
}
]);
}

/**
Expand Down Expand Up @@ -78,9 +88,25 @@ public static function update_settings($request) {
$params = $request->get_params();
$settings = get_option('mayo_settings', []);

// Update BMLT root server
// Update BMLT root server. Validate only when the value actually
// changes so saving unrelated settings (or re-saving an already-valid
// root that is momentarily unreachable) never triggers a network call
// or blocks the save. The bad value is never persisted: an invalid
// root server returns early before any update_option(), preserving the
// previously stored value.
if (isset($params['bmlt_root_server'])) {
$settings['bmlt_root_server'] = sanitize_text_field($params['bmlt_root_server']);
$new_root = sanitize_text_field($params['bmlt_root_server']);
$current_root = $settings['bmlt_root_server'] ?? '';

if ($new_root === '') {
$settings['bmlt_root_server'] = '';
} elseif ($new_root !== $current_root) {
$validated = RootServerValidator::validate($new_root);
if (is_wp_error($validated)) {
return $validated;
}
$settings['bmlt_root_server'] = $validated;
}
}

// Update notification email
Expand Down Expand Up @@ -156,6 +182,36 @@ public static function update_settings($request) {
]);
}

/**
* Validate a BMLT root server URL without saving it.
*
* Backs the "Test connection" affordance in the admin Settings UI.
*
* @param \WP_REST_Request $request
* @return \WP_REST_Response|\WP_Error
*/
public static function validate_root_server($request) {
if (!current_user_can('manage_options')) {
return new \WP_Error(
'rest_forbidden',
__('Sorry, you are not allowed to validate settings.', 'mayo-events-manager'),
['status' => 401]
);
}

$url = sanitize_text_field($request->get_param('bmlt_root_server') ?? '');
$result = RootServerValidator::validate($url);

if (is_wp_error($result)) {
return $result;
}

return new \WP_REST_Response([
'success' => true,
'bmlt_root_server' => $result,
]);
}

/**
* Generate a readable ID for external sources
*
Expand Down
Loading