Skip to content

Commit 80de76e

Browse files
authored
Merge pull request #283 from bmlt-enabled/feature/mayo-282-validate-root-server
Validate BMLT root server on save (reject invalid/unreachable hostnames) [#282]
2 parents 5ddd0f6 + f1a5172 commit 80de76e

9 files changed

Lines changed: 608 additions & 85 deletions

File tree

assets/js/src/components/admin/Settings.js

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const Settings = () => {
7878
});
7979
const [isLoading, setIsLoading] = useState(true);
8080
const [isSaving, setIsSaving] = useState(false);
81+
const [isTesting, setIsTesting] = useState(false);
8182
const [error, setError] = useState(null);
8283
const [successMessage, setSuccessMessage] = useState(null);
8384
const [externalSources, setExternalSources] = useState([]);
@@ -338,6 +339,30 @@ const Settings = () => {
338339
}
339340
};
340341

342+
const handleTestConnection = async () => {
343+
try {
344+
setIsTesting(true);
345+
setError(null);
346+
setSuccessMessage(null);
347+
348+
if (!isValidHttpsUrl(settings.bmlt_root_server)) {
349+
throw new Error(__('BMLT Root Server URL must use HTTPS protocol.', 'mayo-events-manager'));
350+
}
351+
352+
await apiFetch('/validate-root-server', {
353+
method: 'POST',
354+
body: JSON.stringify({ bmlt_root_server: settings.bmlt_root_server })
355+
});
356+
357+
setSuccessMessage(__('Successfully connected to the BMLT root server.', 'mayo-events-manager'));
358+
setTimeout(() => setSuccessMessage(null), 3000);
359+
} catch (err) {
360+
setError(err.message || __('Could not reach the BMLT root server.', 'mayo-events-manager'));
361+
} finally {
362+
setIsTesting(false);
363+
}
364+
};
365+
341366
if (isLoading) {
342367
return (
343368
<div className="mayo-settings-loading">
@@ -381,22 +406,34 @@ const Settings = () => {
381406
<Panel>
382407
<PanelBody title={__('BMLT Settings', 'mayo-events-manager')} initialOpen={true}>
383408
<PanelRow>
384-
<TextControl
385-
label={__('BMLT Root Server URL', 'mayo-events-manager')}
386-
value={settings.bmlt_root_server}
387-
onChange={(value) => handleChange('bmlt_root_server', value)}
388-
help={
389-
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
390-
? __("URL must start with 'https://'", 'mayo-events-manager')
391-
: __('Enter the URL of your BMLT root server (e.g., https://bmlt.example.org/main_server)', 'mayo-events-manager')
392-
}
393-
className={
394-
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
395-
? 'mayo-invalid-url'
396-
: ''
397-
}
398-
__next40pxDefaultSize={true}
399-
/>
409+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%' }}>
410+
<div style={{ flex: 1 }}>
411+
<TextControl
412+
label={__('BMLT Root Server URL', 'mayo-events-manager')}
413+
value={settings.bmlt_root_server}
414+
onChange={(value) => handleChange('bmlt_root_server', value)}
415+
help={
416+
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
417+
? __("URL must start with 'https://'", 'mayo-events-manager')
418+
: __('Enter the URL of your BMLT root server (e.g., https://bmlt.example.org/main_server)', 'mayo-events-manager')
419+
}
420+
className={
421+
settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server)
422+
? 'mayo-invalid-url'
423+
: ''
424+
}
425+
__next40pxDefaultSize={true}
426+
/>
427+
</div>
428+
<Button
429+
isSecondary
430+
onClick={handleTestConnection}
431+
isBusy={isTesting}
432+
disabled={isTesting || !isValidHttpsUrl(settings.bmlt_root_server)}
433+
>
434+
{isTesting ? __('Testing…', 'mayo-events-manager') : __('Test connection', 'mayo-events-manager')}
435+
</Button>
436+
</div>
400437
</PanelRow>
401438

402439
<PanelRow>

assets/js/src/util.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,18 @@ export const apiFetch = async (endpoint, options = {}) => {
217217
const response = await fetch(url, fetchOptions);
218218

219219
if (!response.ok) {
220-
throw new Error(`API error: ${response.status} ${response.statusText}`);
220+
// Prefer the REST API error message (e.g. WP_Error) when present
221+
// so callers can show a meaningful reason instead of a bare status.
222+
let message = `API error: ${response.status} ${response.statusText}`;
223+
try {
224+
const errorBody = await response.json();
225+
if (errorBody && errorBody.message) {
226+
message = errorBody.message;
227+
}
228+
} catch (e) {
229+
// Response body wasn't JSON; keep the status-based message.
230+
}
231+
throw new Error(message);
221232
}
222233

223234
return await response.json();
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
namespace BmltEnabled\Mayo\Rest\Helpers;
4+
5+
if ( ! defined( 'ABSPATH' ) ) exit;
6+
7+
/**
8+
* Validates that a BMLT root server URL is well-formed and reachable.
9+
*
10+
* Performs cheap checks first (format, scheme) before an outbound handshake
11+
* against the BMLT semantic endpoint to confirm the URL actually points at a
12+
* BMLT root server.
13+
*/
14+
class RootServerValidator {
15+
16+
/**
17+
* Timeout, in seconds, for the outbound reachability handshake.
18+
*
19+
* Kept shorter than the read-path lookup in ServiceBodyLookup (15s) so a
20+
* slow host can't hang a settings save for long.
21+
*/
22+
const TIMEOUT = 10;
23+
24+
/**
25+
* Validate and normalize a BMLT root server URL.
26+
*
27+
* @param string $url The candidate root server URL.
28+
* @return string|\WP_Error Normalized URL (no trailing slash) on success,
29+
* or a WP_Error describing why validation failed.
30+
*/
31+
public static function validate( $url ) {
32+
$url = is_string( $url ) ? trim( $url ) : '';
33+
34+
if ( $url === '' ) {
35+
return new \WP_Error(
36+
'invalid_root_server_url',
37+
__( 'Please enter a BMLT root server URL.', 'mayo-events-manager' ),
38+
[ 'status' => 400 ]
39+
);
40+
}
41+
42+
// 1. Format — must be a valid absolute URL.
43+
$normalized = untrailingslashit( esc_url_raw( $url ) );
44+
45+
if ( empty( $normalized ) || ! wp_http_validate_url( $normalized ) ) {
46+
return new \WP_Error(
47+
'invalid_root_server_url',
48+
sprintf(
49+
/* translators: %s: the URL the user entered. */
50+
__( '"%s" is not a valid URL.', 'mayo-events-manager' ),
51+
$url
52+
),
53+
[ 'status' => 400 ]
54+
);
55+
}
56+
57+
// 2. Scheme — require https.
58+
$scheme = strtolower( (string) wp_parse_url( $normalized, PHP_URL_SCHEME ) );
59+
if ( $scheme !== 'https' ) {
60+
return new \WP_Error(
61+
'invalid_root_server_url',
62+
sprintf(
63+
/* translators: %s: the URL the user entered. */
64+
__( 'The BMLT root server URL must start with "https://" (got "%s").', 'mayo-events-manager' ),
65+
$url
66+
),
67+
[ 'status' => 400 ]
68+
);
69+
}
70+
71+
// 3. Reachability — handshake against the BMLT semantic endpoint.
72+
$response = wp_remote_get(
73+
$normalized . '/client_interface/json/?switcher=GetServerInfo',
74+
[
75+
'timeout' => self::TIMEOUT,
76+
'sslverify' => true,
77+
]
78+
);
79+
80+
if ( is_wp_error( $response ) ) {
81+
return new \WP_Error(
82+
'root_server_unreachable',
83+
sprintf(
84+
/* translators: %s: the BMLT root server URL. */
85+
__( 'Could not reach a BMLT root server at %s', 'mayo-events-manager' ),
86+
$normalized
87+
),
88+
[ 'status' => 400 ]
89+
);
90+
}
91+
92+
$code = (int) wp_remote_retrieve_response_code( $response );
93+
if ( $code !== 200 ) {
94+
return new \WP_Error(
95+
'root_server_unreachable',
96+
sprintf(
97+
/* translators: 1: the BMLT root server URL, 2: the HTTP status code returned. */
98+
__( 'Could not reach a BMLT root server at %1$s (HTTP %2$d).', 'mayo-events-manager' ),
99+
$normalized,
100+
$code
101+
),
102+
[ 'status' => 400 ]
103+
);
104+
}
105+
106+
$data = json_decode( wp_remote_retrieve_body( $response ), true );
107+
if ( ! self::looks_like_server_info( $data ) ) {
108+
return new \WP_Error(
109+
'not_a_bmlt_root_server',
110+
sprintf(
111+
/* translators: %s: the BMLT root server URL. */
112+
__( 'The URL %s did not respond like a BMLT root server.', 'mayo-events-manager' ),
113+
$normalized
114+
),
115+
[ 'status' => 400 ]
116+
);
117+
}
118+
119+
return $normalized;
120+
}
121+
122+
/**
123+
* Determine whether a decoded GetServerInfo response looks like a BMLT
124+
* root server. GetServerInfo returns a single-element list of objects
125+
* carrying a `version` field, e.g. [ { "version": "3.0.0", ... } ].
126+
*
127+
* @param mixed $data Decoded JSON response body.
128+
* @return bool
129+
*/
130+
private static function looks_like_server_info( $data ) {
131+
if ( ! is_array( $data ) ) {
132+
return false;
133+
}
134+
135+
if ( isset( $data['version'] ) ) {
136+
return true;
137+
}
138+
139+
return isset( $data[0] ) && is_array( $data[0] ) && isset( $data[0]['version'] );
140+
}
141+
}

includes/Rest/SettingsController.php

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace BmltEnabled\Mayo\Rest;
44

5+
use BmltEnabled\Mayo\Rest\Helpers\RootServerValidator;
6+
57
if ( ! defined( 'ABSPATH' ) ) exit;
68

79
/**
@@ -26,6 +28,14 @@ public static function register_routes() {
2628
return current_user_can('manage_options');
2729
}
2830
]);
31+
32+
register_rest_route('event-manager/v1', '/validate-root-server', [
33+
'methods' => 'POST',
34+
'callback' => [__CLASS__, 'validate_root_server'],
35+
'permission_callback' => function() {
36+
return current_user_can('manage_options');
37+
}
38+
]);
2939
}
3040

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

81-
// Update BMLT root server
91+
// Update BMLT root server. Validate only when the value actually
92+
// changes so saving unrelated settings (or re-saving an already-valid
93+
// root that is momentarily unreachable) never triggers a network call
94+
// or blocks the save. The bad value is never persisted: an invalid
95+
// root server returns early before any update_option(), preserving the
96+
// previously stored value.
8297
if (isset($params['bmlt_root_server'])) {
83-
$settings['bmlt_root_server'] = sanitize_text_field($params['bmlt_root_server']);
98+
$new_root = sanitize_text_field($params['bmlt_root_server']);
99+
$current_root = $settings['bmlt_root_server'] ?? '';
100+
101+
if ($new_root === '') {
102+
$settings['bmlt_root_server'] = '';
103+
} elseif ($new_root !== $current_root) {
104+
$validated = RootServerValidator::validate($new_root);
105+
if (is_wp_error($validated)) {
106+
return $validated;
107+
}
108+
$settings['bmlt_root_server'] = $validated;
109+
}
84110
}
85111

86112
// Update notification email
@@ -156,6 +182,36 @@ public static function update_settings($request) {
156182
]);
157183
}
158184

185+
/**
186+
* Validate a BMLT root server URL without saving it.
187+
*
188+
* Backs the "Test connection" affordance in the admin Settings UI.
189+
*
190+
* @param \WP_REST_Request $request
191+
* @return \WP_REST_Response|\WP_Error
192+
*/
193+
public static function validate_root_server($request) {
194+
if (!current_user_can('manage_options')) {
195+
return new \WP_Error(
196+
'rest_forbidden',
197+
__('Sorry, you are not allowed to validate settings.', 'mayo-events-manager'),
198+
['status' => 401]
199+
);
200+
}
201+
202+
$url = sanitize_text_field($request->get_param('bmlt_root_server') ?? '');
203+
$result = RootServerValidator::validate($url);
204+
205+
if (is_wp_error($result)) {
206+
return $result;
207+
}
208+
209+
return new \WP_REST_Response([
210+
'success' => true,
211+
'bmlt_root_server' => $result,
212+
]);
213+
}
214+
159215
/**
160216
* Generate a readable ID for external sources
161217
*

0 commit comments

Comments
 (0)