PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-backup/src/abilities/class-backup-abilities.php +930 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,930 @@
1 +<?php
2 +/**
3 + * Jetpack Backup Abilities Registration.
4 + *
5 + * Registers Jetpack Backup abilities with the WordPress Abilities API so AI
6 + * agents can read backup status and trigger on-demand backups through the
7 + * standard `wp-abilities/v1` REST surface.
8 + *
9 + * @package automattic/jetpack-backup
10 + */
11 +
12 +namespace Automattic\Jetpack\Backup\V0005\Abilities;
13 +
14 +use Automattic\Jetpack\Backup\V0005\Jetpack_Backup;
15 +use Automattic\Jetpack\My_Jetpack\Products\Backup as My_Jetpack_Backup;
16 +use Automattic\Jetpack\WP_Abilities\Registrar;
17 +use WP_Error;
18 +use WP_REST_Response;
19 +
20 +if ( ! defined( 'ABSPATH' ) ) {
21 + exit( 0 );
22 +}
23 +
24 +/**
25 + * Registers Jetpack Backup abilities with the WordPress Abilities API.
26 + *
27 + * Exposes a small, agent-friendly surface for site backups:
28 + *
29 + * - `jetpack-backup/get-backup-overview` — single-call site backup health snapshot.
30 + * - `jetpack-backup/list-backups` — recent backups with optional id/pagination filters.
31 + * - `jetpack-backup/list-restores` — recent restores with optional id/pagination filters.
32 + * - `jetpack-backup/request-backup` — enqueue an on-demand backup.
33 + */
34 +class Backup_Abilities extends Registrar {
35 +
36 + const PER_PAGE_DEFAULT = 20;
37 + const PER_PAGE_MAX = 100;
38 +
39 + /**
40 + * Return the ability category slug.
41 + *
42 + * @return string
43 + */
44 + public static function get_category_slug(): string {
45 + return 'site';
46 + }
47 +
48 + /**
49 + * Required by the abstract parent, but unused: the `site` category is
50 + * already registered upstream (WordPress core / wpcom), so we don't
51 + * re-declare it. Kept so the contract holds if a consumer ever asks for
52 + * the definition we *would* use.
53 + *
54 + * @return array
55 + */
56 + public static function get_category_definition(): array {
57 + return array(
58 + 'label' => __( 'Site', 'jetpack-backup-pkg' ),
59 + 'description' => __( 'Site-wide management abilities (registered upstream).', 'jetpack-backup-pkg' ),
60 + );
61 + }
62 +
63 + /**
64 + * Override the Registrar lifecycle so the backup abilities only register
65 + * on sites that actually have a Jetpack Backup product provisioned.
66 + * There's no point exposing tool surfaces an agent can never use, and on free
67 + * sites the upstream wpcom endpoints either silently accept writes (e.g.
68 + * `request-backup` reported `enqueued: true`) or return null payloads
69 + * that confuse callers.
70 + *
71 + * The `site` category is registered upstream by WordPress core / wpcom,
72 + * so this class never tries to register a category — `register_category`
73 + * is a no-op even though the parent hooks it.
74 + *
75 + * @return void
76 + */
77 + public static function register_category() {
78 + // No-op: `site` is registered upstream; re-registering would either
79 + // no-op or trigger "already registered" notices.
80 + }
81 +
82 + /**
83 + * Register every ability returned by `get_abilities()`, gated on the
84 + * Backup product being loaded. See `register_category()` for why we
85 + * never register a category from here.
86 + *
87 + * @return void
88 + */
89 + public static function register_abilities() {
90 + if ( ! self::backup_is_loaded() ) {
91 + return;
92 + }
93 + parent::register_abilities();
94 + }
95 +
96 + /**
97 + * Is the Jetpack Backup product actually loaded on this site?
98 + *
99 + * True when a plugin that ships Backup is active and the site has a Backup plan. Deliberately
100 + * not `My_Jetpack\Products\Backup::is_active()`: that also follows the backup module, which only
101 + * switches the wp-admin dashboard, while backups keep running on WordPress.com. The plan lookup
102 + * is cached in `MY_JETPACK_SITE_FEATURES_TRANSIENT_KEY`.
103 + *
104 + * The `jetpack_backup_abilities_should_load` filter lets consumers and
105 + * tests override the answer without round-tripping through the My
106 + * Jetpack product class.
107 + *
108 + * @return bool
109 + */
110 + private static function backup_is_loaded(): bool {
111 + $default = class_exists( My_Jetpack_Backup::class )
112 + && My_Jetpack_Backup::is_plugin_active()
113 + && My_Jetpack_Backup::has_any_plan_for_product();
114 +
115 + /**
116 + * Filters whether the Jetpack Backup abilities should register on
117 + * this site. Defaults to whether Backup's plugin is active and the site has a Backup plan.
118 + *
119 + * @since 0.1.0
120 + *
121 + * @param bool $should_load Whether to register the backup abilities.
122 + */
123 + return (bool) apply_filters( 'jetpack_backup_abilities_should_load', $default );
124 + }
125 +
126 + /**
127 + * Return the abilities this Registrar exposes, keyed by slug.
128 + *
129 + * @return array<string, array<string, mixed>>
130 + */
131 + public static function get_abilities(): array {
132 + // `id` is the rewind_id (a timestamp-fractional string like
133 + // "1752860369.781") — the single cross-system identifier exposed by
134 + // the wpcom rewind, restore, and activity-log APIs. Use it whenever
135 + // referring to a specific backup across abilities.
136 + //
137 + // For in-progress backup attempts the rewind_id isn't assigned yet,
138 + // in which case `id` is null. Such backups can't be looked up by id
139 + // anywhere — wait for completion and re-query.
140 + $backup_item_schema = array(
141 + 'type' => 'object',
142 + 'properties' => array(
143 + 'id' => array( 'type' => array( 'string', 'null' ) ),
144 + 'started' => array( 'type' => array( 'string', 'null' ) ),
145 + 'last_updated' => array( 'type' => array( 'string', 'null' ) ),
146 + 'status' => array( 'type' => array( 'string', 'null' ) ),
147 + 'period' => array( 'type' => array( 'string', 'integer', 'null' ) ),
148 + 'is_rewindable' => array( 'type' => array( 'boolean', 'null' ) ),
149 + 'has_warnings' => array( 'type' => array( 'boolean', 'null' ) ),
150 + ),
151 + );
152 +
153 + // Same convention applies to restores: `id` is the rewind_id of the
154 + // backup being restored to.
155 + $restore_item_schema = array(
156 + 'type' => 'object',
157 + 'properties' => array(
158 + 'id' => array( 'type' => array( 'string', 'null' ) ),
159 + 'started' => array( 'type' => array( 'string', 'null' ) ),
160 + 'last_updated' => array( 'type' => array( 'string', 'null' ) ),
161 + 'status' => array( 'type' => array( 'string', 'null' ) ),
162 + 'progress' => array( 'type' => array( 'integer', 'null' ) ),
163 + ),
164 + );
165 +
166 + return array(
167 + 'jetpack-backup/get-backup-overview' => array(
168 + 'label' => __( 'Get backup overview', 'jetpack-backup-pkg' ),
169 + 'description' => __(
170 + 'Return a single-call snapshot of the site backup state: { last_backup, recent_backup_count, schedule, storage }. Use this to answer "is my site protected?" before deciding whether to call list-backups, list-restores, or request-backup. Read-only and idempotent. Fields whose backing service is unreachable come back as null rather than failing the call. Requires the manage_options capability.',
171 + 'jetpack-backup-pkg'
172 + ),
173 + 'input_schema' => array(
174 + 'type' => 'object',
175 + 'default' => array(),
176 + 'properties' => array(),
177 + 'additionalProperties' => false,
178 + ),
179 + 'output_schema' => array(
180 + 'type' => 'object',
181 + 'properties' => array(
182 + 'recent_backup_count' => array( 'type' => array( 'integer', 'null' ) ),
183 + 'last_backup' => array(
184 + 'type' => array( 'object', 'null' ),
185 + 'properties' => array(
186 + 'id' => array( 'type' => array( 'string', 'null' ) ),
187 + 'last_updated' => array( 'type' => array( 'string', 'null' ) ),
188 + 'status' => array( 'type' => array( 'string', 'null' ) ),
189 + 'is_rewindable' => array( 'type' => array( 'boolean', 'null' ) ),
190 + 'has_warnings' => array( 'type' => array( 'boolean', 'null' ) ),
191 + ),
192 + ),
193 + // Hour only: no WordPress.com endpoint carries a minute,
194 + // so a `minute` field could only ever be null.
195 + 'schedule' => array(
196 + 'type' => array( 'object', 'null' ),
197 + 'properties' => array(
198 + 'hour' => array( 'type' => array( 'integer', 'null' ) ),
199 + ),
200 + ),
201 + 'storage' => array(
202 + 'type' => array( 'object', 'null' ),
203 + 'properties' => array(
204 + 'used_bytes' => array( 'type' => array( 'integer', 'null' ) ),
205 + 'limit_bytes' => array( 'type' => array( 'integer', 'null' ) ),
206 + ),
207 + ),
208 + ),
209 + ),
210 + 'execute_callback' => array( __CLASS__, 'execute_get_backup_overview' ),
211 + 'permission_callback' => array( __CLASS__, 'can_view_backups' ),
212 + 'meta' => array(
213 + 'annotations' => array(
214 + 'readonly' => true,
215 + 'destructive' => false,
216 + 'idempotent' => true,
217 + ),
218 + 'mcp' => array(
219 + 'public' => true,
220 + 'type' => 'tool',
221 + ),
222 + 'show_in_rest' => true,
223 + ),
224 + ),
225 +
226 + 'jetpack-backup/list-backups' => array(
227 + 'label' => __( 'List backups', 'jetpack-backup-pkg' ),
228 + 'description' => __(
229 + 'Return zero or more backups as an array. Each item summarises one backup: { id, rewind_id, started, last_updated, status, period, is_rewindable, has_warnings }. Combine filters to narrow the result without making multiple calls. `id` returns a 0- or 1-element array for a single rewind_id. `date_from` and `date_to` window the results (ISO 8601 datetimes; server-side filter). `date` + `match` ("on_or_before" default, "on_or_after", "closest") pick a single backup near a target datetime — useful for "find a restore point near this incident"; the response stays a 0- or 1-element array. `status` filters by mapped status (e.g. "finished", "error"). `page` + `per_page` paginate the result; iterate `page=1,2,...` until you get an empty array. A page may come back with fewer than `per_page` items even when more pages exist — `status` is applied client-side, and non-backup events are filtered out — so only an empty page reliably signals end of history. Read-only and idempotent. Backed by the wpcom activity-log feed.',
230 + 'jetpack-backup-pkg'
231 + ),
232 + 'input_schema' => array(
233 + 'type' => 'object',
234 + 'default' => array(),
235 + 'properties' => array(
236 + 'id' => array(
237 + 'type' => 'string',
238 + 'description' => __( 'Return only the backup with this rewind_id. Unknown ids yield an empty array.', 'jetpack-backup-pkg' ),
239 + 'minLength' => 1,
240 + ),
241 + 'date_from' => array(
242 + 'type' => 'string',
243 + 'format' => 'date-time',
244 + 'description' => __( 'Lower bound (inclusive) on backup `started` time. ISO 8601 datetime, e.g. "2026-04-01T00:00:00Z".', 'jetpack-backup-pkg' ),
245 + 'minLength' => 1,
246 + ),
247 + 'date_to' => array(
248 + 'type' => 'string',
249 + 'format' => 'date-time',
250 + 'description' => __( 'Upper bound (inclusive) on backup `started` time. ISO 8601 datetime, e.g. "2026-04-30T23:59:59Z".', 'jetpack-backup-pkg' ),
251 + 'minLength' => 1,
252 + ),
253 + 'date' => array(
254 + 'type' => 'string',
255 + 'format' => 'date-time',
256 + 'description' => __( 'Target datetime to find a single matching backup. When set, the response is a 0- or 1-element array. Pair with `match` to choose direction.', 'jetpack-backup-pkg' ),
257 + 'minLength' => 1,
258 + ),
259 + 'match' => array(
260 + 'type' => 'string',
261 + 'enum' => array( 'on_or_before', 'on_or_after', 'closest' ),
262 + 'default' => 'on_or_before',
263 + 'description' => __( 'How to interpret `date`. "on_or_before" (default; the latest backup at or before the target — typical for restores), "on_or_after" (earliest backup at or after), or "closest" (smallest absolute time difference). Ignored when `date` is not set.', 'jetpack-backup-pkg' ),
264 + ),
265 + 'status' => array(
266 + 'type' => 'string',
267 + 'description' => __( 'Filter by mapped status string (e.g. "finished", "error"). Applied client-side after the server query.', 'jetpack-backup-pkg' ),
268 + 'minLength' => 1,
269 + ),
270 + 'page' => array(
271 + 'type' => 'integer',
272 + 'description' => __( '1-based page number. Ignored when `id` or `date` is set (those are single-result lookups).', 'jetpack-backup-pkg' ),
273 + 'default' => 1,
274 + 'minimum' => 1,
275 + ),
276 + 'per_page' => array(
277 + 'type' => 'integer',
278 + 'description' => __( 'Cap on items returned per page (default 20, max 100). Also bounds the server-side query window the date filters are evaluated against.', 'jetpack-backup-pkg' ),
279 + 'default' => self::PER_PAGE_DEFAULT,
280 + 'minimum' => 1,
281 + 'maximum' => self::PER_PAGE_MAX,
282 + ),
283 + ),
284 + 'additionalProperties' => false,
285 + ),
286 + 'output_schema' => array(
287 + 'type' => 'array',
288 + 'items' => $backup_item_schema,
289 + ),
290 + 'execute_callback' => array( __CLASS__, 'execute_list_backups' ),
291 + 'permission_callback' => array( __CLASS__, 'can_view_backups' ),
292 + 'meta' => array(
293 + 'annotations' => array(
294 + 'readonly' => true,
295 + 'destructive' => false,
296 + 'idempotent' => true,
297 + ),
298 + 'mcp' => array(
299 + 'public' => true,
300 + 'type' => 'tool',
301 + ),
302 + 'show_in_rest' => true,
303 + ),
304 + ),
305 +
306 + 'jetpack-backup/list-restores' => array(
307 + 'label' => __( 'List restores', 'jetpack-backup-pkg' ),
308 + 'description' => __(
309 + 'Return zero or more recent restore operations as an array. Each item: { id, rewind_id, started, last_updated, status, progress }. Pass id to fetch a single restore (returns 0- or 1-element array). Otherwise paginate with page and per_page (default 20, max 100). Read-only and idempotent.',
310 + 'jetpack-backup-pkg'
311 + ),
312 + 'input_schema' => array(
313 + 'type' => 'object',
314 + 'default' => array(),
315 + 'properties' => array(
316 + 'id' => array(
317 + 'type' => 'string',
318 + 'description' => __( 'Return only the restore with this id. Unknown ids yield an empty array.', 'jetpack-backup-pkg' ),
319 + 'minLength' => 1,
320 + ),
321 + 'page' => array(
322 + 'type' => 'integer',
323 + 'description' => __( 'Page number, 1-based.', 'jetpack-backup-pkg' ),
324 + 'default' => 1,
325 + 'minimum' => 1,
326 + ),
327 + 'per_page' => array(
328 + 'type' => 'integer',
329 + 'description' => __( 'Items per page (default 20, max 100).', 'jetpack-backup-pkg' ),
330 + 'default' => self::PER_PAGE_DEFAULT,
331 + 'minimum' => 1,
332 + 'maximum' => self::PER_PAGE_MAX,
333 + ),
334 + ),
335 + 'additionalProperties' => false,
336 + ),
337 + 'output_schema' => array(
338 + 'type' => 'array',
339 + 'items' => $restore_item_schema,
340 + ),
341 + 'execute_callback' => array( __CLASS__, 'execute_list_restores' ),
342 + 'permission_callback' => array( __CLASS__, 'can_view_backups' ),
343 + 'meta' => array(
344 + 'annotations' => array(
345 + 'readonly' => true,
346 + 'destructive' => false,
347 + 'idempotent' => true,
348 + ),
349 + 'mcp' => array(
350 + 'public' => true,
351 + 'type' => 'tool',
352 + ),
353 + 'show_in_rest' => true,
354 + ),
355 + ),
356 +
357 + 'jetpack-backup/request-backup' => array(
358 + 'label' => __( 'Request a backup', 'jetpack-backup-pkg' ),
359 + 'description' => __(
360 + 'Enqueue an on-demand backup of this site. Returns { enqueued: bool, message: string }. Each successful call queues a new backup job; this is a state-changing write, not idempotent. Use get-backup-overview or list-backups afterwards to track progress. Requires the manage_options capability. Returns jetpack_backup_data_unavailable when the upstream service rejects the request.',
361 + 'jetpack-backup-pkg'
362 + ),
363 + 'input_schema' => array(
364 + 'type' => 'object',
365 + 'default' => array(),
366 + 'properties' => array(),
367 + 'additionalProperties' => false,
368 + ),
369 + 'output_schema' => array(
370 + 'type' => 'object',
371 + 'properties' => array(
372 + 'enqueued' => array( 'type' => 'boolean' ),
373 + 'message' => array( 'type' => 'string' ),
374 + ),
375 + ),
376 + 'execute_callback' => array( __CLASS__, 'execute_request_backup' ),
377 + 'permission_callback' => array( __CLASS__, 'can_manage_backups' ),
378 + 'meta' => array(
379 + 'annotations' => array(
380 + 'readonly' => false,
381 + 'destructive' => false,
382 + 'idempotent' => false,
383 + ),
384 + 'mcp' => array(
385 + 'public' => true,
386 + 'type' => 'tool',
387 + ),
388 + 'show_in_rest' => true,
389 + ),
390 + ),
391 + );
392 + }
393 +
394 + /**
395 + * Permission check for read abilities. Gates on `manage_options` to
396 + * match the existing REST controller (see
397 + * Jetpack_Backup::backups_permissions_callback). Kept separate from
398 + * `can_manage_backups()` so the read and write surfaces can diverge
399 + * later without touching every spec.
400 + *
401 + * @return bool
402 + */
403 + public static function can_view_backups(): bool {
404 + return current_user_can( 'manage_options' );
405 + }
406 +
407 + /**
408 + * Permission check for write abilities. See `can_view_backups()`.
409 + *
410 + * @return bool
411 + */
412 + public static function can_manage_backups(): bool {
413 + return current_user_can( 'manage_options' );
414 + }
415 +
416 + /**
417 + * Composite read: each subfield is null on upstream failure rather than
418 + * failing the whole call, so a partial wpcom outage degrades to "missing
419 + * pieces" instead of "no data." Registration is gated on a Backup product
420 + * being loaded (see register_abilities), so this callback assumes the
421 + * site has one and only reports on the data it can fetch.
422 + *
423 + * @param mixed $input Unused; ability accepts no input. Typed `mixed` because
424 + * the Abilities API may pass the raw caller-supplied value
425 + * (string/null/array) before our `additionalProperties:false`
426 + * schema runs — a strict array type would fatal on garbage input.
427 + * @return array
428 + */
429 + public static function execute_get_backup_overview( $input = null ): array {
430 + unset( $input );
431 +
432 + $backups = self::unwrap_response( Jetpack_Backup::get_recent_backups() );
433 + $schedule_data = self::unwrap_response( Jetpack_Backup::get_site_backup_schedule_time() );
434 + $size_data = self::unwrap_response( Jetpack_Backup::get_site_backup_size() );
435 + // Two round-trips because neither route describes storage alone: `/rewind/size`
436 + // reports usage, `/rewind/policies` the limit. Fetched unconditionally so a
437 + // usable limit still arrives when usage cannot be measured.
438 + $policies_data = self::unwrap_response( Jetpack_Backup::get_site_backup_policies() );
439 +
440 + return array(
441 + 'recent_backup_count' => is_array( $backups ) ? count( $backups ) : null,
442 + 'last_backup' => self::summarize_last_backup( is_array( $backups ) ? ( $backups[0] ?? null ) : null ),
443 + 'schedule' => self::summarize_schedule( $schedule_data ),
444 + 'storage' => self::summarize_storage( $size_data, $policies_data ),
445 + );
446 + }
447 +
448 + /**
449 + * Consolidated read: queries the wpcom activity-log rewindable feed
450 + * (server-side date filtering, up to 1000 items/page) and reshapes
451 + * activity events back into the backup-item schema. All input filters
452 + * land here; the picker is invoked when a `date` + `match` is set.
453 + *
454 + * @param mixed $input See input_schema on `jetpack-backup/list-backups`.
455 + * @return array|WP_Error
456 + */
457 + public static function execute_list_backups( $input = null ) {
458 + $input = is_array( $input ) ? $input : array();
459 +
460 + // Validate `date` / `date_from` / `date_to` ahead of the round-trip so
461 + // agents get a specific error rather than a 200 with mysterious empty
462 + // results. Schema's `format: date-time` is advisory in WP REST.
463 + foreach ( array( 'date', 'date_from', 'date_to' ) as $key ) {
464 + if ( isset( $input[ $key ] ) && '' !== $input[ $key ] && null === self::parse_timestamp( $input[ $key ] ) ) {
465 + return new WP_Error(
466 + 'jetpack_backup_invalid_date',
467 + /* translators: %s is an input parameter name. */
468 + sprintf( __( 'The `%s` parameter must be a valid ISO 8601 datetime (e.g. "2026-05-13T14:30:00Z").', 'jetpack-backup-pkg' ), $key )
469 + );
470 + }
471 + }
472 +
473 + $per_page = min(
474 + self::PER_PAGE_MAX,
475 + max( 1, isset( $input['per_page'] ) ? (int) $input['per_page'] : self::PER_PAGE_DEFAULT )
476 + );
477 + $page = max( 1, isset( $input['page'] ) ? (int) $input['page'] : 1 );
478 +
479 + // `page` is suppressed on the single-result lookups (id, date+match)
480 + // — those resolve from page 1 and walking later pages would skip
481 + // candidates without an obvious benefit.
482 + $is_single_lookup = ( isset( $input['id'] ) && '' !== $input['id'] )
483 + || ( isset( $input['date'] ) && '' !== $input['date'] );
484 +
485 + $query = array(
486 + 'number' => $per_page,
487 + 'page' => $is_single_lookup ? 1 : $page,
488 + 'sort_order' => 'desc',
489 + );
490 + if ( isset( $input['date_from'] ) && '' !== $input['date_from'] ) {
491 + $query['after'] = (string) $input['date_from'];
492 + }
493 + if ( isset( $input['date_to'] ) && '' !== $input['date_to'] ) {
494 + $query['before'] = (string) $input['date_to'];
495 + }
496 +
497 + $envelope = self::unwrap_response( Jetpack_Backup::list_backup_events( $query ) );
498 + $events = self::extract_rewindable_items( $envelope );
499 + if ( ! is_array( $events ) ) {
500 + return array();
501 + }
502 +
503 + $items = array_values( array_filter( array_map( array( __CLASS__, 'summarize_backup_event' ), $events ) ) );
504 +
505 + // Single-id filter — same convention as the old endpoint: 0/1-element array.
506 + if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
507 + foreach ( $items as $item ) {
508 + if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
509 + return array( $item );
510 + }
511 + }
512 + return array();
513 + }
514 +
515 + // Client-side status filter (server-side filters by event name, not status).
516 + if ( isset( $input['status'] ) && is_string( $input['status'] ) && '' !== $input['status'] ) {
517 + $want = $input['status'];
518 + $items = array_values(
519 + array_filter(
520 + $items,
521 + static function ( $i ) use ( $want ) {
522 + return ( $i['status'] ?? null ) === $want;
523 + }
524 + )
525 + );
526 + }
527 +
528 + // Single-match shortcut.
529 + if ( isset( $input['date'] ) && '' !== $input['date'] ) {
530 + $target = self::parse_timestamp( $input['date'] );
531 + $match = isset( $input['match'] ) && is_string( $input['match'] ) ? $input['match'] : 'on_or_before';
532 + if ( ! in_array( $match, array( 'on_or_before', 'on_or_after', 'closest' ), true ) ) {
533 + $match = 'on_or_before';
534 + }
535 + $pick = self::pick_backup_near_timestamp( $items, (int) $target, $match );
536 + return null === $pick ? array() : array( $pick );
537 + }
538 +
539 + return array_slice( $items, 0, $per_page );
540 + }
541 +
542 + /**
543 + * Execute callback for `jetpack-backup/list-restores`.
544 + *
545 + * @param mixed $input See input_schema on the ability.
546 + * @return array
547 + */
548 + public static function execute_list_restores( $input = null ): array {
549 + $restores = self::unwrap_response( Jetpack_Backup::get_recent_restores() );
550 + if ( ! is_array( $restores ) ) {
551 + return array();
552 + }
553 +
554 + $summarized = array_map( array( __CLASS__, 'summarize_restore' ), $restores );
555 + return self::apply_id_or_pagination( $summarized, is_array( $input ) ? $input : array() );
556 + }
557 +
558 + /**
559 + * Pure picker for the `date` + `match` shortcut. Operates on already-
560 + * summarized backup items (so it works regardless of which upstream
561 + * helper produced them) and uses `started` as the comparison timestamp.
562 + *
563 + * @param array $items Summarized backup items.
564 + * @param int $target_ts Unix timestamp the caller is searching around.
565 + * @param string $match 'on_or_before' | 'on_or_after' | 'closest'.
566 + * @return array|null The winning item or null when nothing matches.
567 + */
568 + private static function pick_backup_near_timestamp( array $items, int $target_ts, string $match ): ?array {
569 + $best = null;
570 + $best_score = null;
571 +
572 + foreach ( $items as $item ) {
573 + $ts = self::parse_timestamp( $item['started'] ?? null );
574 + if ( null === $ts ) {
575 + continue;
576 + }
577 +
578 + $diff = $ts - $target_ts;
579 + $score = 0;
580 + switch ( $match ) {
581 + case 'on_or_after':
582 + if ( $diff < 0 ) {
583 + continue 2;
584 + }
585 + $score = $diff;
586 + break;
587 + case 'closest':
588 + $score = abs( $diff );
589 + break;
590 + case 'on_or_before':
591 + default:
592 + if ( $diff > 0 ) {
593 + continue 2;
594 + }
595 + $score = -$diff;
596 + break;
597 + }
598 +
599 + if ( null === $best_score || $score < $best_score ) {
600 + $best = $item;
601 + $best_score = $score;
602 + }
603 + }
604 +
605 + return $best;
606 + }
607 +
608 + /**
609 + * Pull the activity-event array out of the W3C ActivityStreams envelope
610 + * that `/activity/rewindable` returns. The endpoint puts the items in
611 + * `current.orderedItems`; older proxy shapes used `orderedItems` at the
612 + * top level, so check both before giving up.
613 + *
614 + * @param mixed $envelope Raw decoded response body.
615 + * @return array|null
616 + */
617 + private static function extract_rewindable_items( $envelope ): ?array {
618 + if ( ! is_array( $envelope ) && ! is_object( $envelope ) ) {
619 + return null;
620 + }
621 + $envelope = (array) $envelope;
622 + if ( isset( $envelope['current'] ) ) {
623 + $current = (array) $envelope['current'];
624 + if ( isset( $current['orderedItems'] ) && is_array( $current['orderedItems'] ) ) {
625 + return $current['orderedItems'];
626 + }
627 + }
628 + if ( isset( $envelope['orderedItems'] ) && is_array( $envelope['orderedItems'] ) ) {
629 + return $envelope['orderedItems'];
630 + }
631 + return null;
632 + }
633 +
634 + /**
635 + * Translate one /activity/rewindable event into the same backup-item
636 + * shape `summarize_backup()` produces, so the ability's output schema
637 + * stays stable across the upstream switch. Returns null for events that
638 + * don't look like backups (no `rewind_id`).
639 + *
640 + * @param mixed $raw One element from `current.orderedItems`.
641 + * @return array|null
642 + */
643 + private static function summarize_backup_event( $raw ): ?array {
644 + if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
645 + return null;
646 + }
647 + $raw = (array) $raw;
648 +
649 + $rewind_id = $raw['rewind_id'] ?? null;
650 + if ( null === $rewind_id || '' === $rewind_id ) {
651 + return null;
652 + }
653 +
654 + $published = isset( $raw['published'] ) && is_string( $raw['published'] ) ? $raw['published'] : null;
655 + $status_raw = isset( $raw['status'] ) && is_string( $raw['status'] ) ? $raw['status'] : null;
656 + $name = isset( $raw['name'] ) && is_string( $raw['name'] ) ? $raw['name'] : '';
657 + $is_rewindable = isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null;
658 +
659 + return array(
660 + 'id' => (string) $rewind_id,
661 + 'started' => $published,
662 + 'last_updated' => $published,
663 + 'status' => self::map_event_status( $status_raw, $name ),
664 + 'period' => self::parse_timestamp( $rewind_id ),
665 + 'is_rewindable' => $is_rewindable,
666 + 'has_warnings' => self::event_has_warnings( $status_raw, $name ),
667 + );
668 + }
669 +
670 + /**
671 + * Map activity-event status / action-name to the status vocabulary the
672 + * ability's output schema uses (the same labels as the old
673 + * `/rewind/backups` endpoint: "finished", "error", ...). Falls back to
674 + * the raw status when no mapping fits so the caller still sees signal.
675 + *
676 + * @param string|null $status_raw Activity event status (e.g. "success", "warning", "error").
677 + * @param string $name Activity name, e.g. "rewind__backup_complete_full".
678 + * @return string|null
679 + */
680 + private static function map_event_status( ?string $status_raw, string $name ): ?string {
681 + if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
682 + return 'finished';
683 + }
684 + return $status_raw;
685 + }
686 +
687 + /**
688 + * Derive `has_warnings` from an activity event's status / name.
689 + *
690 + * @param string|null $status_raw Activity event status.
691 + * @param string $name Activity name.
692 + * @return bool|null
693 + */
694 + private static function event_has_warnings( ?string $status_raw, string $name ): ?bool {
695 + if ( 'warning' === $status_raw ) {
696 + return true;
697 + }
698 + if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
699 + return false;
700 + }
701 + return null;
702 + }
703 +
704 + /**
705 + * Coerce an ISO 8601 string, RFC-style date string, or numeric unix
706 + * timestamp to an int unix timestamp. Returns null for anything that
707 + * can't be unambiguously parsed (instead of strtotime's `false`, which
708 + * is also a valid timestamp for 1969-12-31).
709 + *
710 + * Fractional numeric strings (e.g. rewind_id "1778804242.107") are
711 + * accepted — the fractional part is truncated.
712 + *
713 + * @param mixed $value Source value (string, int, float, or anything else).
714 + * @return int|null
715 + */
716 + private static function parse_timestamp( $value ): ?int {
717 + if ( is_int( $value ) ) {
718 + return $value;
719 + }
720 + if ( is_float( $value ) ) {
721 + return (int) $value;
722 + }
723 + if ( is_string( $value ) && '' !== $value ) {
724 + if ( is_numeric( $value ) ) {
725 + return (int) $value;
726 + }
727 + $ts = strtotime( $value );
728 + return false === $ts ? null : $ts;
729 + }
730 + return null;
731 + }
732 +
733 + /**
734 + * Enqueue an on-demand backup. Registration is gated on a Backup product
735 + * being loaded so we assume one exists by the time this runs. Returns
736 + * WP_Error only when the upstream connection itself fails so agents can
737 + * retry strategically.
738 + *
739 + * @param mixed $input Unused; see note on execute_get_backup_overview().
740 + * @return array|WP_Error
741 + */
742 + public static function execute_request_backup( $input = null ) {
743 + unset( $input );
744 +
745 + // wpcom can return HTTP 200 with `{ success: false, error: ... }`; treat
746 + // that as a failure rather than reporting the backup was enqueued.
747 + $result = self::unwrap_response( Jetpack_Backup::enqueue_backup() );
748 + if ( ! is_array( $result ) || empty( $result['success'] ) ) {
749 + return new WP_Error(
750 + 'jetpack_backup_data_unavailable',
751 + __( 'The backup service did not accept the request. The connection to WordPress.com may be temporarily unavailable; retry shortly.', 'jetpack-backup-pkg' )
752 + );
753 + }
754 +
755 + return array(
756 + 'enqueued' => true,
757 + 'message' => __( 'Backup enqueued. Use jetpack-backup/list-backups to monitor progress.', 'jetpack-backup-pkg' ),
758 + );
759 + }
760 +
761 + /**
762 + * Normalize a Jetpack_Backup helper result (WP_REST_Response, array, null,
763 + * or WP_Error) to a plain value or null. Jetpack_Backup uses
764 + * `rest_ensure_response()` on success; on failure its routes return a
765 + * WP_Error and `list_backup_events()` returns null, so abilities need
766 + * every shape flattened before summarising.
767 + *
768 + * @param mixed $maybe_response Result of a Jetpack_Backup helper call.
769 + * @return mixed
770 + */
771 + private static function unwrap_response( $maybe_response ) {
772 + if ( null === $maybe_response || is_wp_error( $maybe_response ) ) {
773 + return null;
774 + }
775 + if ( $maybe_response instanceof WP_REST_Response ) {
776 + return $maybe_response->get_data();
777 + }
778 + return $maybe_response;
779 + }
780 +
781 + /**
782 + * Slice the (already-summarized) list down to a single id, or apply
783 + * page/per_page pagination. Always returns the same item shape.
784 + *
785 + * @param array $items Summarized items.
786 + * @param array $input Sanitized input.
787 + * @return array
788 + */
789 + private static function apply_id_or_pagination( array $items, array $input ): array {
790 + if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
791 + foreach ( $items as $item ) {
792 + if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
793 + return array( $item );
794 + }
795 + }
796 + return array();
797 + }
798 +
799 + $page = max( 1, (int) ( $input['page'] ?? 1 ) );
800 + $per_page = min( self::PER_PAGE_MAX, max( 1, (int) ( $input['per_page'] ?? self::PER_PAGE_DEFAULT ) ) );
801 +
802 + return array_slice( $items, ( $page - 1 ) * $per_page, $per_page );
803 + }
804 +
805 + /**
806 + * High-signal summary used inside `last_backup` for the overview. Same as
807 + * `summarize_backup` minus the `started`/`period` fields which the agent
808 + * doesn't need at a glance.
809 + *
810 + * @param mixed $raw One element from the upstream backups list.
811 + * @return array|null
812 + */
813 + private static function summarize_last_backup( $raw ): ?array {
814 + if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
815 + return null;
816 + }
817 + return array_diff_key(
818 + self::summarize_backup( $raw ),
819 + array_flip( array( 'started', 'period' ) )
820 + );
821 + }
822 +
823 + /**
824 + * Summarize a `/rewind/backups` payload item using `rewind_id` as the
825 + * canonical `id`. The numeric attempt id wpcom also exposes is
826 + * internal to VaultPress and can't be looked up via any other endpoint,
827 + * so it's intentionally dropped from the agent-facing shape — see the
828 + * note on `$backup_item_schema` in `get_abilities()`.
829 + *
830 + * @param mixed $raw Upstream backup item.
831 + * @return array
832 + */
833 + private static function summarize_backup( $raw ): array {
834 + $raw = (array) $raw;
835 + $rewind_id = $raw['rewind_id'] ?? null;
836 + return array(
837 + 'id' => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
838 + 'started' => $raw['started'] ?? null,
839 + 'last_updated' => $raw['last_updated'] ?? null,
840 + 'status' => $raw['status'] ?? null,
841 + 'period' => $raw['period'] ?? null,
842 + 'is_rewindable' => isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null,
843 + 'has_warnings' => isset( $raw['has_warnings'] ) ? (bool) $raw['has_warnings'] : null,
844 + );
845 + }
846 +
847 + /**
848 + * Summarize a `/rewind/restores` payload item. `id` is the rewind_id
849 + * of the backup being restored to — same canonical id system as
850 + * `summarize_backup()`.
851 + *
852 + * @param mixed $raw Upstream restore item.
853 + * @return array
854 + */
855 + private static function summarize_restore( $raw ): array {
856 + $raw = (array) $raw;
857 + $rewind_id = $raw['rewind_id'] ?? null;
858 + return array(
859 + 'id' => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
860 + 'started' => $raw['started'] ?? null,
861 + 'last_updated' => $raw['last_updated'] ?? null,
862 + 'status' => $raw['status'] ?? null,
863 + 'progress' => isset( $raw['progress'] ) ? (int) $raw['progress'] : null,
864 + );
865 + }
866 +
867 + /**
868 + * Summarize the `/site/backup/schedule` payload to `{ hour }`.
869 + *
870 + * WordPress.com answers `{ ok, scheduled_hour, scheduled_by }` — the UTC hour of the
871 + * daily backup, and no minute anywhere. `ok` is its own success flag inside a 200
872 + * body, so a payload without it carries no usable hour.
873 + *
874 + * @param mixed $raw Upstream schedule payload.
875 + * @return array|null
876 + */
877 + private static function summarize_schedule( $raw ): ?array {
878 + if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
879 + return null;
880 + }
881 + $raw = (array) $raw;
882 + if ( empty( $raw['ok'] ) ) {
883 + return null;
884 + }
885 + return array(
886 + 'hour' => isset( $raw['scheduled_hour'] ) ? (int) $raw['scheduled_hour'] : null,
887 + );
888 + }
889 +
890 + /**
891 + * Summarize storage from the two payloads that between them describe it.
892 + *
893 + * Usage is `size` on `/site/backup/size`, which despite its name carries no limit;
894 + * the limit is `policies.storage_limit_bytes` on `/site/backup/policies`.
895 + *
896 + * The two are read independently, so a site whose usage could not be measured still
897 + * reports what it is allowed. That is deliberately unlike `summarize_schedule()`,
898 + * which has nothing left to report once its hour is gone.
899 + *
900 + * @param mixed $size_raw Upstream `/site/backup/size` payload.
901 + * @param mixed $policies_raw Upstream `/site/backup/policies` payload.
902 + * @return array|null
903 + */
904 + private static function summarize_storage( $size_raw, $policies_raw ): ?array {
905 + $size = ( is_array( $size_raw ) || is_object( $size_raw ) ) ? (array) $size_raw : null;
906 + $policies = ( is_array( $policies_raw ) || is_object( $policies_raw ) ) ? (array) $policies_raw : null;
907 +
908 + if ( null === $size && null === $policies ) {
909 + return null;
910 + }
911 +
912 + $used_bytes = ( null !== $size && ! empty( $size['ok'] ) && isset( $size['size'] ) )
913 + ? (int) $size['size']
914 + : null;
915 +
916 + // `policies` is itself nullable inside a 200: a plan with no retention policy
917 + // answers `{ "policies": null }`.
918 + $policy = $policies['policies'] ?? null;
919 + $policy = ( is_array( $policy ) || is_object( $policy ) ) ? (array) $policy : null;
920 +
921 + $limit_bytes = ( null !== $policy && isset( $policy['storage_limit_bytes'] ) )
922 + ? (int) $policy['storage_limit_bytes']
923 + : null;
924 +
925 + return array(
926 + 'used_bytes' => $used_bytes,
927 + 'limit_bytes' => $limit_bytes,
928 + );
929 + }
930 +}