PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
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 1 month ago class-monitor-abilities.php 1 month ago
class-monitor-abilities.php
415 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 // @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions needed for older-WP compatibility runs.
11
12 namespace Automattic\Jetpack\Plugin\Abilities;
13
14 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15 use Automattic\Jetpack\WP_Abilities\Registrar;
16 use Jetpack;
17 use Jetpack_IXR_Client;
18
19 /**
20 * Registers Jetpack Downtime Monitor abilities with the WordPress Abilities API.
21 *
22 * Exposes a zero-arg overview read (`get-monitor-status`) and a declarative
23 * state-setter (`set-notifications`) so AI agents can inspect and configure the
24 * site's Downtime Monitor through the standard `wp-abilities/v1` REST surface.
25 */
26 class Monitor_Abilities extends Registrar {
27
28 private const MODULE_SLUG = 'monitor';
29
30 /**
31 * {@inheritDoc}
32 *
33 * Monitor abilities live under the WordPress core `site` category — it is
34 * registered by the Abilities API itself, so we reference it by slug and
35 * never register it ourselves (see the no-op `register_category()` below).
36 */
37 public static function get_category_slug(): string {
38 return 'site';
39 }
40
41 /**
42 * {@inheritDoc}
43 *
44 * Unused: the `site` category is owned by WordPress core, so
45 * `register_category()` is a no-op and this definition is never passed to
46 * `wp_register_ability_category()`. It remains only to satisfy the abstract
47 * Registrar contract.
48 */
49 public static function get_category_definition(): array {
50 return array();
51 }
52
53 /**
54 * No-op: the `site` ability category is registered by the WordPress core
55 * Abilities API. Re-registering it here would clobber the core definition,
56 * so this registrar only references the category by slug.
57 *
58 * @return void
59 */
60 public static function register_category() {}
61
62 /**
63 * {@inheritDoc}
64 */
65 public static function get_abilities(): array {
66 return array(
67 'jetpack-monitor/get-monitor-status' => array(
68 'label' => __( 'Get Jetpack Monitor status', 'jetpack' ),
69 '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' ),
70 'input_schema' => array(
71 'type' => 'object',
72 'additionalProperties' => false,
73 ),
74 'output_schema' => array(
75 'type' => 'object',
76 'properties' => array(
77 'module_active' => array( 'type' => 'boolean' ),
78 'user_connected' => array( 'type' => 'boolean' ),
79 'notifications_enabled' => array( 'type' => 'boolean' ),
80 'last_status_change' => array( 'type' => array( 'string', 'null' ) ),
81 ),
82 ),
83 'execute_callback' => array( __CLASS__, 'get_monitor_status' ),
84 'permission_callback' => array( __CLASS__, 'can_view_monitor' ),
85 'meta' => array(
86 'annotations' => array(
87 'readonly' => true,
88 'destructive' => false,
89 'idempotent' => true,
90 ),
91 'show_in_rest' => true,
92 'mcp' => array(
93 'public' => true,
94 'type' => 'tool',
95 ),
96 ),
97 ),
98
99 'jetpack-monitor/set-notifications' => array(
100 'label' => __( 'Set Jetpack Monitor notifications', 'jetpack' ),
101 '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' ),
102 'input_schema' => array(
103 'type' => 'object',
104 'required' => array( 'enabled' ),
105 'properties' => array(
106 'enabled' => array(
107 'type' => 'boolean',
108 'description' => __( 'Desired notification state. true enables downtime email notifications for the current user; false disables them.', 'jetpack' ),
109 ),
110 ),
111 'additionalProperties' => false,
112 ),
113 'output_schema' => array(
114 'type' => 'object',
115 'properties' => array(
116 'enabled' => array( 'type' => 'boolean' ),
117 'changed' => array( 'type' => 'boolean' ),
118 ),
119 ),
120 'execute_callback' => array( __CLASS__, 'set_notifications' ),
121 'permission_callback' => array( __CLASS__, 'can_manage_monitor' ),
122 'meta' => array(
123 'annotations' => array(
124 'readonly' => false,
125 'destructive' => false,
126 'idempotent' => true,
127 ),
128 'show_in_rest' => true,
129 'mcp' => array(
130 'public' => true,
131 'type' => 'tool',
132 ),
133 ),
134 ),
135 );
136 }
137
138 /**
139 * Permission check: can the current user read Monitor status?
140 */
141 public static function can_view_monitor(): bool {
142 return current_user_can( 'jetpack_admin_page' );
143 }
144
145 /**
146 * Permission check: can the current user manage Monitor notifications?
147 *
148 * Notifications are a per-user preference that affects the caller's own inbox,
149 * so `jetpack_admin_page` (the same capability that gates the admin settings UI)
150 * is the right gate — no stricter cap is warranted.
151 */
152 public static function can_manage_monitor(): bool {
153 return current_user_can( 'jetpack_admin_page' );
154 }
155
156 /**
157 * Execute: overview read. Returns the full
158 * `{ module_active, user_connected, notifications_enabled, last_status_change }`
159 * shape on the happy path. Surfaces precondition and transport failures as
160 * `WP_Error` so callers (especially AI agents) get an actionable next step
161 * instead of opaque null fields:
162 *
163 * - `jetpack_monitor_module_inactive` — Monitor module is not active.
164 * Defensive: in practice this is unreachable because the abilities are
165 * only registered while the module is active.
166 * - `jetpack_monitor_not_connected` — the current user is not connected to
167 * Jetpack; the remote read needs the user's token. Steers the caller to
168 * the My Jetpack admin page to connect.
169 * - `jetpack_monitor_service_unreachable` — the remote Monitor service
170 * returned an error for one of the two underlying XML-RPC reads
171 * (`isUserInNotifications` or `getLastDowntime`). Transient — retry later.
172 *
173 * `last_status_change` remains `null` on the happy path when no up/down
174 * transition has been recorded yet; that is the documented "no data yet"
175 * signal, not a failure.
176 *
177 * @param array|null $input Ability input (no parameters accepted).
178 * @return array|\WP_Error
179 */
180 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.
181 $module_active = Jetpack::is_module_active( self::MODULE_SLUG );
182 $user_connected = static::is_user_connected_to_jetpack();
183
184 if ( ! $module_active ) {
185 return new \WP_Error(
186 'jetpack_monitor_module_inactive',
187 __( 'The Monitor module is not active. Activate it before reading Monitor status.', 'jetpack' )
188 );
189 }
190
191 if ( ! $user_connected ) {
192 return new \WP_Error(
193 'jetpack_monitor_not_connected',
194 __( 'User is not connected to Jetpack. Connect first via the My Jetpack admin page, then retry this ability.', 'jetpack' )
195 );
196 }
197
198 $state = static::fetch_notifications_state();
199 if ( is_wp_error( $state ) ) {
200 return new \WP_Error(
201 'jetpack_monitor_service_unreachable',
202 __( 'The remote Jetpack Monitor service is unreachable. Retry shortly; this is typically transient.', 'jetpack' ),
203 array( 'underlying' => $state->get_error_code() )
204 );
205 }
206
207 $status_change = static::fetch_last_status_change();
208 if ( is_wp_error( $status_change ) ) {
209 return new \WP_Error(
210 'jetpack_monitor_service_unreachable',
211 __( 'The remote Jetpack Monitor service is unreachable. Retry shortly; this is typically transient.', 'jetpack' ),
212 array( 'underlying' => $status_change->get_error_code() )
213 );
214 }
215
216 return array(
217 'module_active' => $module_active,
218 'user_connected' => $user_connected,
219 'notifications_enabled' => (bool) $state,
220 'last_status_change' => $status_change,
221 );
222 }
223
224 /**
225 * Execute: declarative state-setter. Idempotent — compares desired vs current
226 * and returns changed=false when they match. Either way the local
227 * `monitor_receive_notifications` option is synced to the remote value (after
228 * the write on a change, and on the no-op path) so the legacy REST reader,
229 * which trusts that option first, never reports a stale state.
230 *
231 * @param array|null $input Input matching the ability's input_schema.
232 * @return array|\WP_Error
233 */
234 public static function set_notifications( $input = null ) {
235 $input = is_array( $input ) ? $input : array();
236
237 if ( ! array_key_exists( 'enabled', $input ) ) {
238 return new \WP_Error(
239 'jetpack_monitor_missing_enabled',
240 __( 'A desired enabled state (boolean) is required.', 'jetpack' )
241 );
242 }
243 if ( ! is_bool( $input['enabled'] ) ) {
244 return new \WP_Error(
245 'jetpack_monitor_invalid_enabled',
246 __( 'The enabled parameter must be a boolean. Strings like "true" / "false" are not accepted.', 'jetpack' )
247 );
248 }
249
250 if ( ! Jetpack::is_module_active( self::MODULE_SLUG ) ) {
251 return new \WP_Error(
252 'jetpack_monitor_module_inactive',
253 __( 'The Monitor module is not active. Activate it before configuring notifications.', 'jetpack' )
254 );
255 }
256
257 if ( ! static::is_user_connected_to_jetpack() ) {
258 return new \WP_Error(
259 'jetpack_monitor_not_connected',
260 __( 'The current user is not connected to Jetpack. Connect the user to Jetpack before configuring Monitor notifications.', 'jetpack' )
261 );
262 }
263
264 $desired = $input['enabled'];
265 $current = static::fetch_notifications_state();
266 if ( is_wp_error( $current ) ) {
267 return $current;
268 }
269
270 if ( $desired === $current ) {
271 // Sync the local `monitor_receive_notifications` option to the
272 // known-good remote value even on a no-op. The legacy
273 // `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader trusts
274 // this option before falling back to a remote read, so a stale local
275 // value would let it report the wrong state. The changed=true path
276 // below mirrors the option after a write; mirroring here keeps the
277 // unchanged path self-healing too.
278 update_option( 'monitor_receive_notifications', $current );
279
280 return array(
281 'enabled' => $current,
282 'changed' => false,
283 );
284 }
285
286 $applied = static::apply_notifications_update( $desired );
287 if ( is_wp_error( $applied ) ) {
288 return $applied;
289 }
290
291 // Mirror the write to the `monitor_receive_notifications` option so the
292 // legacy `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader — the
293 // only other reader of this option — stays in sync with the remote state.
294 update_option( 'monitor_receive_notifications', $desired );
295
296 return array(
297 'enabled' => $desired,
298 'changed' => true,
299 );
300 }
301
302 /**
303 * Whether the current user is connected to Jetpack.
304 *
305 * Extracted as a protected seam so tests can override the connection check
306 * without standing up a full Jetpack token fixture.
307 */
308 protected static function is_user_connected_to_jetpack(): bool {
309 return ( new Connection_Manager( 'jetpack' ) )->is_user_connected();
310 }
311
312 /**
313 * Send the IXR `jetpack.monitor.setNotifications` request to apply the
314 * desired state on the remote Monitor service.
315 *
316 * @param bool $enabled Desired notification state.
317 * @return true|\WP_Error True on success, WP_Error on remote failure.
318 */
319 protected static function apply_notifications_update( bool $enabled ) {
320 $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
321 $xml->query( 'jetpack.monitor.setNotifications', $enabled );
322 if ( $xml->isError() ) {
323 return new \WP_Error(
324 'jetpack_monitor_notifications_update_failed',
325 sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
326 );
327 }
328 return true;
329 }
330
331 /**
332 * Fetch the current notifications state from the remote Monitor service.
333 *
334 * @return bool|\WP_Error Boolean preference when the remote call succeeds,
335 * WP_Error when the remote call fails.
336 */
337 protected static function fetch_notifications_state() {
338 $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
339 $xml->query( 'jetpack.monitor.isUserInNotifications' );
340 if ( $xml->isError() ) {
341 return new \WP_Error(
342 'jetpack_monitor_notifications_data_unavailable',
343 sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
344 );
345 }
346 return (bool) $xml->getResponse();
347 }
348
349 /**
350 * Fetch the last up/down status-change timestamp from the remote Monitor
351 * service, reusing the same transient key and 10-minute TTL written by the
352 * legacy module.
353 *
354 * The remote `jetpack.monitor.getLastDowntime` XML-RPC method returns the
355 * legacy `last_status_change` projection — the time of the most recent
356 * up/down transition, not strictly when downtime began. The transient key
357 * stays `monitor_last_downtime` because that is what the legacy module
358 * writes and we share its cache.
359 *
360 * @return string|null|\WP_Error YYYY-MM-DD HH:mm:ss string, null when no
361 * transition has been recorded, or WP_Error
362 * on a remote failure.
363 */
364 protected static function fetch_last_status_change() {
365 $cached = get_transient( 'monitor_last_downtime' );
366 if ( false !== $cached ) {
367 return self::normalize_last_status_change( $cached );
368 }
369
370 $xml = new Jetpack_IXR_Client();
371 $xml->query( 'jetpack.monitor.getLastDowntime' );
372 if ( $xml->isError() ) {
373 return new \WP_Error(
374 'jetpack_monitor_downtime_data_unavailable',
375 sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
376 );
377 }
378
379 $response = $xml->getResponse();
380 set_transient( 'monitor_last_downtime', $response, 10 * MINUTE_IN_SECONDS );
381 return self::normalize_last_status_change( $response );
382 }
383
384 /**
385 * Normalize a `last_status_change` value into the documented contract:
386 * a `YYYY-MM-DD HH:mm:ss` UTC string, or `null` for "no transition yet".
387 *
388 * Jetpack Monitor v1 returns an empty string when no transition has been
389 * recorded; Monitor v2 may instead surface a MySQL zero-date
390 * (`0000-00-00 00:00:00`) or some other sentinel. Collapse every "no value"
391 * representation to `null` so the ability's `null` contract stays stable
392 * regardless of which backend is active.
393 *
394 * @param mixed $value Raw remote/cached value.
395 * @return string|null Pass-through timestamp string, or null when absent.
396 */
397 protected static function normalize_last_status_change( $value ) {
398 if ( ! is_string( $value ) ) {
399 return null;
400 }
401
402 $value = trim( $value );
403 if ( '' === $value || 0 === strncmp( $value, '0000-00-00', 10 ) ) {
404 return null;
405 }
406
407 $ts = strtotime( $value );
408 if ( false === $ts || $ts <= 0 ) {
409 return null;
410 }
411
412 return $value;
413 }
414 }
415