PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
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 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-backup / src / abilities / class-backup-abilities.php

class-backup-abilities.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/jetpack-backup/src/abilities/class-backup-abilities.php

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