PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / trunk
Jetpack – WP Security, Backup, Speed, & Growth vtrunk
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / src / abilities / class-monitor-abilities.php
jetpack / src / abilities Last commit date
class-modules-abilities.php 2 months ago class-monitor-abilities.php 1 week ago
class-monitor-abilities.php
413 lines
1 <?php
2 /**
3 * Jetpack Monitor Abilities Registration
4 *
5 * Registers Jetpack Downtime Monitor abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack
8 */
9
10 namespace Automattic\Jetpack\Plugin\Abilities;
11
12 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13 use Automattic\Jetpack\WP_Abilities\Registrar;
14 use Jetpack;
15 use Jetpack_IXR_Client;
16
17 /**
18 * Registers Jetpack Downtime Monitor abilities with the WordPress Abilities API.
19 *
20 * Exposes a zero-arg overview read (`get-monitor-status`) and a declarative
21 * state-setter (`set-notifications`) so AI agents can inspect and configure the
22 * site's Downtime Monitor through the standard `wp-abilities/v1` REST surface.
23 */
24 class Monitor_Abilities extends Registrar {
25
26 private const MODULE_SLUG = 'monitor';
27
28 /**
29 * {@inheritDoc}
30 *
31 * Monitor abilities live under the WordPress core `site` category — it is
32 * registered by the Abilities API itself, so we reference it by slug and
33 * never register it ourselves (see the no-op `register_category()` below).
34 */
35 public static function get_category_slug(): string {
36 return 'site';
37 }
38
39 /**
40 * {@inheritDoc}
41 *
42 * Unused: the `site` category is owned by WordPress core, so
43 * `register_category()` is a no-op and this definition is never passed to
44 * `wp_register_ability_category()`. It remains only to satisfy the abstract
45 * Registrar contract.
46 */
47 public static function get_category_definition(): array {
48 return array();
49 }
50
51 /**
52 * No-op: the `site` ability category is registered by the WordPress core
53 * Abilities API. Re-registering it here would clobber the core definition,
54 * so this registrar only references the category by slug.
55 *
56 * @return void
57 */
58 public static function register_category() {}
59
60 /**
61 * {@inheritDoc}
62 */
63 public static function get_abilities(): array {
64 return array(
65 'jetpack-monitor/get-monitor-status' => array(
66 'label' => __( 'Get Jetpack Monitor status', 'jetpack' ),
67 'description' => __( 'Return the current Downtime Monitor state as { module_active, user_connected, notifications_enabled, last_status_change }. notifications_enabled is a boolean (does the current user receive downtime alerts). last_status_change is the timestamp of the most recent up/down status transition recorded by the Monitor service, as a "YYYY-MM-DD HH:mm:ss" UTC string, or null when no transition has been recorded — this reflects the legacy last_status_change projection, not necessarily the last time downtime began. Fails with jetpack_monitor_not_connected when the current user is not connected to Jetpack (connect first via the My Jetpack admin page), or jetpack_monitor_service_unreachable when the remote Monitor service cannot be reached. These abilities are only registered while the Monitor module is active; if they are absent from wp_get_abilities(), activate the Monitor module first.', 'jetpack' ),
68 'input_schema' => array(
69 'type' => 'object',
70 'additionalProperties' => false,
71 ),
72 'output_schema' => array(
73 'type' => 'object',
74 'properties' => array(
75 'module_active' => array( 'type' => 'boolean' ),
76 'user_connected' => array( 'type' => 'boolean' ),
77 'notifications_enabled' => array( 'type' => 'boolean' ),
78 'last_status_change' => array( 'type' => array( 'string', 'null' ) ),
79 ),
80 ),
81 'execute_callback' => array( __CLASS__, 'get_monitor_status' ),
82 'permission_callback' => array( __CLASS__, 'can_view_monitor' ),
83 'meta' => array(
84 'annotations' => array(
85 'readonly' => true,
86 'destructive' => false,
87 'idempotent' => true,
88 ),
89 'show_in_rest' => true,
90 'mcp' => array(
91 'public' => true,
92 'type' => 'tool',
93 ),
94 ),
95 ),
96
97 'jetpack-monitor/set-notifications' => array(
98 'label' => __( 'Set Jetpack Monitor notifications', 'jetpack' ),
99 'description' => __( 'Enable or disable downtime email notifications for the current user. Idempotent — setting the state to the current value returns changed=false. Returns { enabled, changed }. Preconditions: the Monitor module must be active and the current user must be connected to Jetpack; call jetpack-monitor/get-monitor-status first to verify the connection. Fails with jetpack_monitor_module_inactive (activate the Monitor module first — these abilities are only registered while the module is active, so this error indicates a race) or jetpack_monitor_not_connected when preconditions are not met.', 'jetpack' ),
100 'input_schema' => array(
101 'type' => 'object',
102 'required' => array( 'enabled' ),
103 'properties' => array(
104 'enabled' => array(
105 'type' => 'boolean',
106 'description' => __( 'Desired notification state. true enables downtime email notifications for the current user; false disables them.', 'jetpack' ),
107 ),
108 ),
109 'additionalProperties' => false,
110 ),
111 'output_schema' => array(
112 'type' => 'object',
113 'properties' => array(
114 'enabled' => array( 'type' => 'boolean' ),
115 'changed' => array( 'type' => 'boolean' ),
116 ),
117 ),
118 'execute_callback' => array( __CLASS__, 'set_notifications' ),
119 'permission_callback' => array( __CLASS__, 'can_manage_monitor' ),
120 'meta' => array(
121 'annotations' => array(
122 'readonly' => false,
123 'destructive' => false,
124 'idempotent' => true,
125 ),
126 'show_in_rest' => true,
127 'mcp' => array(
128 'public' => true,
129 'type' => 'tool',
130 ),
131 ),
132 ),
133 );
134 }
135
136 /**
137 * Permission check: can the current user read Monitor status?
138 */
139 public static function can_view_monitor(): bool {
140 return current_user_can( 'jetpack_admin_page' );
141 }
142
143 /**
144 * Permission check: can the current user manage Monitor notifications?
145 *
146 * Notifications are a per-user preference that affects the caller's own inbox,
147 * so `jetpack_admin_page` (the same capability that gates the admin settings UI)
148 * is the right gate — no stricter cap is warranted.
149 */
150 public static function can_manage_monitor(): bool {
151 return current_user_can( 'jetpack_admin_page' );
152 }
153
154 /**
155 * Execute: overview read. Returns the full
156 * `{ module_active, user_connected, notifications_enabled, last_status_change }`
157 * shape on the happy path. Surfaces precondition and transport failures as
158 * `WP_Error` so callers (especially AI agents) get an actionable next step
159 * instead of opaque null fields:
160 *
161 * - `jetpack_monitor_module_inactive` — Monitor module is not active.
162 * Defensive: in practice this is unreachable because the abilities are
163 * only registered while the module is active.
164 * - `jetpack_monitor_not_connected` — the current user is not connected to
165 * Jetpack; the remote read needs the user's token. Steers the caller to
166 * the My Jetpack admin page to connect.
167 * - `jetpack_monitor_service_unreachable` — the remote Monitor service
168 * returned an error for one of the two underlying XML-RPC reads
169 * (`isUserInNotifications` or `getLastDowntime`). Transient — retry later.
170 *
171 * `last_status_change` remains `null` on the happy path when no up/down
172 * transition has been recorded yet; that is the documented "no data yet"
173 * signal, not a failure.
174 *
175 * @param array|null $input Ability input (no parameters accepted).
176 * @return array|\WP_Error
177 */
178 public static function get_monitor_status( $input = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Abilities API contract requires execute callbacks to accept the input array even when the schema declares no parameters.
179 $module_active = Jetpack::is_module_active( self::MODULE_SLUG );
180 $user_connected = static::is_user_connected_to_jetpack();
181
182 if ( ! $module_active ) {
183 return new \WP_Error(
184 'jetpack_monitor_module_inactive',
185 __( 'The Monitor module is not active. Activate it before reading Monitor status.', 'jetpack' )
186 );
187 }
188
189 if ( ! $user_connected ) {
190 return new \WP_Error(
191 'jetpack_monitor_not_connected',
192 __( 'User is not connected to Jetpack. Connect first via the My Jetpack admin page, then retry this ability.', 'jetpack' )
193 );
194 }
195
196 $state = static::fetch_notifications_state();
197 if ( is_wp_error( $state ) ) {
198 return new \WP_Error(
199 'jetpack_monitor_service_unreachable',
200 __( 'The remote Jetpack Monitor service is unreachable. Retry shortly; this is typically transient.', 'jetpack' ),
201 array( 'underlying' => $state->get_error_code() )
202 );
203 }
204
205 $status_change = static::fetch_last_status_change();
206 if ( is_wp_error( $status_change ) ) {
207 return new \WP_Error(
208 'jetpack_monitor_service_unreachable',
209 __( 'The remote Jetpack Monitor service is unreachable. Retry shortly; this is typically transient.', 'jetpack' ),
210 array( 'underlying' => $status_change->get_error_code() )
211 );
212 }
213
214 return array(
215 'module_active' => $module_active,
216 'user_connected' => $user_connected,
217 'notifications_enabled' => (bool) $state,
218 'last_status_change' => $status_change,
219 );
220 }
221
222 /**
223 * Execute: declarative state-setter. Idempotent — compares desired vs current
224 * and returns changed=false when they match. Either way the local
225 * `monitor_receive_notifications` option is synced to the remote value (after
226 * the write on a change, and on the no-op path) so the legacy REST reader,
227 * which trusts that option first, never reports a stale state.
228 *
229 * @param array|null $input Input matching the ability's input_schema.
230 * @return array|\WP_Error
231 */
232 public static function set_notifications( $input = null ) {
233 $input = is_array( $input ) ? $input : array();
234
235 if ( ! array_key_exists( 'enabled', $input ) ) {
236 return new \WP_Error(
237 'jetpack_monitor_missing_enabled',
238 __( 'A desired enabled state (boolean) is required.', 'jetpack' )
239 );
240 }
241 if ( ! is_bool( $input['enabled'] ) ) {
242 return new \WP_Error(
243 'jetpack_monitor_invalid_enabled',
244 __( 'The enabled parameter must be a boolean. Strings like "true" / "false" are not accepted.', 'jetpack' )
245 );
246 }
247
248 if ( ! Jetpack::is_module_active( self::MODULE_SLUG ) ) {
249 return new \WP_Error(
250 'jetpack_monitor_module_inactive',
251 __( 'The Monitor module is not active. Activate it before configuring notifications.', 'jetpack' )
252 );
253 }
254
255 if ( ! static::is_user_connected_to_jetpack() ) {
256 return new \WP_Error(
257 'jetpack_monitor_not_connected',
258 __( 'The current user is not connected to Jetpack. Connect the user to Jetpack before configuring Monitor notifications.', 'jetpack' )
259 );
260 }
261
262 $desired = $input['enabled'];
263 $current = static::fetch_notifications_state();
264 if ( is_wp_error( $current ) ) {
265 return $current;
266 }
267
268 if ( $desired === $current ) {
269 // Sync the local `monitor_receive_notifications` option to the
270 // known-good remote value even on a no-op. The legacy
271 // `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader trusts
272 // this option before falling back to a remote read, so a stale local
273 // value would let it report the wrong state. The changed=true path
274 // below mirrors the option after a write; mirroring here keeps the
275 // unchanged path self-healing too.
276 update_option( 'monitor_receive_notifications', $current );
277
278 return array(
279 'enabled' => $current,
280 'changed' => false,
281 );
282 }
283
284 $applied = static::apply_notifications_update( $desired );
285 if ( is_wp_error( $applied ) ) {
286 return $applied;
287 }
288
289 // Mirror the write to the `monitor_receive_notifications` option so the
290 // legacy `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader — the
291 // only other reader of this option — stays in sync with the remote state.
292 update_option( 'monitor_receive_notifications', $desired );
293
294 return array(
295 'enabled' => $desired,
296 'changed' => true,
297 );
298 }
299
300 /**
301 * Whether the current user is connected to Jetpack.
302 *
303 * Extracted as a protected seam so tests can override the connection check
304 * without standing up a full Jetpack token fixture.
305 */
306 protected static function is_user_connected_to_jetpack(): bool {
307 return ( new Connection_Manager( 'jetpack' ) )->is_user_connected();
308 }
309
310 /**
311 * Send the IXR `jetpack.monitor.setNotifications` request to apply the
312 * desired state on the remote Monitor service.
313 *
314 * @param bool $enabled Desired notification state.
315 * @return true|\WP_Error True on success, WP_Error on remote failure.
316 */
317 protected static function apply_notifications_update( bool $enabled ) {
318 $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
319 $xml->query( 'jetpack.monitor.setNotifications', $enabled );
320 if ( $xml->isError() ) {
321 return new \WP_Error(
322 'jetpack_monitor_notifications_update_failed',
323 sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
324 );
325 }
326 return true;
327 }
328
329 /**
330 * Fetch the current notifications state from the remote Monitor service.
331 *
332 * @return bool|\WP_Error Boolean preference when the remote call succeeds,
333 * WP_Error when the remote call fails.
334 */
335 protected static function fetch_notifications_state() {
336 $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
337 $xml->query( 'jetpack.monitor.isUserInNotifications' );
338 if ( $xml->isError() ) {
339 return new \WP_Error(
340 'jetpack_monitor_notifications_data_unavailable',
341 sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
342 );
343 }
344 return (bool) $xml->getResponse();
345 }
346
347 /**
348 * Fetch the last up/down status-change timestamp from the remote Monitor
349 * service, reusing the same transient key and 10-minute TTL written by the
350 * legacy module.
351 *
352 * The remote `jetpack.monitor.getLastDowntime` XML-RPC method returns the
353 * legacy `last_status_change` projection — the time of the most recent
354 * up/down transition, not strictly when downtime began. The transient key
355 * stays `monitor_last_downtime` because that is what the legacy module
356 * writes and we share its cache.
357 *
358 * @return string|null|\WP_Error YYYY-MM-DD HH:mm:ss string, null when no
359 * transition has been recorded, or WP_Error
360 * on a remote failure.
361 */
362 protected static function fetch_last_status_change() {
363 $cached = get_transient( 'monitor_last_downtime' );
364 if ( false !== $cached ) {
365 return self::normalize_last_status_change( $cached );
366 }
367
368 $xml = new Jetpack_IXR_Client();
369 $xml->query( 'jetpack.monitor.getLastDowntime' );
370 if ( $xml->isError() ) {
371 return new \WP_Error(
372 'jetpack_monitor_downtime_data_unavailable',
373 sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
374 );
375 }
376
377 $response = $xml->getResponse();
378 set_transient( 'monitor_last_downtime', $response, 10 * MINUTE_IN_SECONDS );
379 return self::normalize_last_status_change( $response );
380 }
381
382 /**
383 * Normalize a `last_status_change` value into the documented contract:
384 * a `YYYY-MM-DD HH:mm:ss` UTC string, or `null` for "no transition yet".
385 *
386 * Jetpack Monitor v1 returns an empty string when no transition has been
387 * recorded; Monitor v2 may instead surface a MySQL zero-date
388 * (`0000-00-00 00:00:00`) or some other sentinel. Collapse every "no value"
389 * representation to `null` so the ability's `null` contract stays stable
390 * regardless of which backend is active.
391 *
392 * @param mixed $value Raw remote/cached value.
393 * @return string|null Pass-through timestamp string, or null when absent.
394 */
395 protected static function normalize_last_status_change( $value ) {
396 if ( ! is_string( $value ) ) {
397 return null;
398 }
399
400 $value = trim( $value );
401 if ( '' === $value || 0 === strncmp( $value, '0000-00-00', 10 ) ) {
402 return null;
403 }
404
405 $ts = strtotime( $value );
406 if ( false === $ts || $ts <= 0 ) {
407 return null;
408 }
409
410 return $value;
411 }
412 }
413