PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.3
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.3
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-backup / src / abilities / class-backup-abilities.php
jetpack / jetpack_vendor / automattic / jetpack-backup / src / abilities Last commit date
class-backup-abilities.php 2 months ago
class-backup-abilities.php
900 lines
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 'schedule' => array(
195 'type' => array( 'object', 'null' ),
196 'properties' => array(
197 'hour' => array( 'type' => array( 'integer', 'null' ) ),
198 'minute' => 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
436 return array(
437 'recent_backup_count' => is_array( $backups ) ? count( $backups ) : null,
438 'last_backup' => self::summarize_last_backup( is_array( $backups ) ? ( $backups[0] ?? null ) : null ),
439 'schedule' => self::summarize_schedule( $schedule_data ),
440 'storage' => self::summarize_storage( $size_data ),
441 );
442 }
443
444 /**
445 * Consolidated read: queries the wpcom activity-log rewindable feed
446 * (server-side date filtering, up to 1000 items/page) and reshapes
447 * activity events back into the backup-item schema. All input filters
448 * land here; the picker is invoked when a `date` + `match` is set.
449 *
450 * @param mixed $input See input_schema on `jetpack-backup/list-backups`.
451 * @return array|WP_Error
452 */
453 public static function execute_list_backups( $input = null ) {
454 $input = is_array( $input ) ? $input : array();
455
456 // Validate `date` / `date_from` / `date_to` ahead of the round-trip so
457 // agents get a specific error rather than a 200 with mysterious empty
458 // results. Schema's `format: date-time` is advisory in WP REST.
459 foreach ( array( 'date', 'date_from', 'date_to' ) as $key ) {
460 if ( isset( $input[ $key ] ) && '' !== $input[ $key ] && null === self::parse_timestamp( $input[ $key ] ) ) {
461 return new WP_Error(
462 'jetpack_backup_invalid_date',
463 /* translators: %s is an input parameter name. */
464 sprintf( __( 'The `%s` parameter must be a valid ISO 8601 datetime (e.g. "2026-05-13T14:30:00Z").', 'jetpack-backup-pkg' ), $key )
465 );
466 }
467 }
468
469 $per_page = min(
470 self::PER_PAGE_MAX,
471 max( 1, isset( $input['per_page'] ) ? (int) $input['per_page'] : self::PER_PAGE_DEFAULT )
472 );
473 $page = max( 1, isset( $input['page'] ) ? (int) $input['page'] : 1 );
474
475 // `page` is suppressed on the single-result lookups (id, date+match)
476 // — those resolve from page 1 and walking later pages would skip
477 // candidates without an obvious benefit.
478 $is_single_lookup = ( isset( $input['id'] ) && '' !== $input['id'] )
479 || ( isset( $input['date'] ) && '' !== $input['date'] );
480
481 $query = array(
482 'number' => $per_page,
483 'page' => $is_single_lookup ? 1 : $page,
484 'sort_order' => 'desc',
485 );
486 if ( isset( $input['date_from'] ) && '' !== $input['date_from'] ) {
487 $query['after'] = (string) $input['date_from'];
488 }
489 if ( isset( $input['date_to'] ) && '' !== $input['date_to'] ) {
490 $query['before'] = (string) $input['date_to'];
491 }
492
493 $envelope = self::unwrap_response( Jetpack_Backup::list_backup_events( $query ) );
494 $events = self::extract_rewindable_items( $envelope );
495 if ( ! is_array( $events ) ) {
496 return array();
497 }
498
499 $items = array_values( array_filter( array_map( array( __CLASS__, 'summarize_backup_event' ), $events ) ) );
500
501 // Single-id filter — same convention as the old endpoint: 0/1-element array.
502 if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
503 foreach ( $items as $item ) {
504 if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
505 return array( $item );
506 }
507 }
508 return array();
509 }
510
511 // Client-side status filter (server-side filters by event name, not status).
512 if ( isset( $input['status'] ) && is_string( $input['status'] ) && '' !== $input['status'] ) {
513 $want = $input['status'];
514 $items = array_values(
515 array_filter(
516 $items,
517 static function ( $i ) use ( $want ) {
518 return ( $i['status'] ?? null ) === $want;
519 }
520 )
521 );
522 }
523
524 // Single-match shortcut.
525 if ( isset( $input['date'] ) && '' !== $input['date'] ) {
526 $target = self::parse_timestamp( $input['date'] );
527 $match = isset( $input['match'] ) && is_string( $input['match'] ) ? $input['match'] : 'on_or_before';
528 if ( ! in_array( $match, array( 'on_or_before', 'on_or_after', 'closest' ), true ) ) {
529 $match = 'on_or_before';
530 }
531 $pick = self::pick_backup_near_timestamp( $items, (int) $target, $match );
532 return null === $pick ? array() : array( $pick );
533 }
534
535 return array_slice( $items, 0, $per_page );
536 }
537
538 /**
539 * Execute callback for `jetpack-backup/list-restores`.
540 *
541 * @param mixed $input See input_schema on the ability.
542 * @return array
543 */
544 public static function execute_list_restores( $input = null ): array {
545 $restores = self::unwrap_response( Jetpack_Backup::get_recent_restores() );
546 if ( ! is_array( $restores ) ) {
547 return array();
548 }
549
550 $summarized = array_map( array( __CLASS__, 'summarize_restore' ), $restores );
551 return self::apply_id_or_pagination( $summarized, is_array( $input ) ? $input : array() );
552 }
553
554 /**
555 * Pure picker for the `date` + `match` shortcut. Operates on already-
556 * summarized backup items (so it works regardless of which upstream
557 * helper produced them) and uses `started` as the comparison timestamp.
558 *
559 * @param array $items Summarized backup items.
560 * @param int $target_ts Unix timestamp the caller is searching around.
561 * @param string $match 'on_or_before' | 'on_or_after' | 'closest'.
562 * @return array|null The winning item or null when nothing matches.
563 */
564 private static function pick_backup_near_timestamp( array $items, int $target_ts, string $match ): ?array {
565 $best = null;
566 $best_score = null;
567
568 foreach ( $items as $item ) {
569 $ts = self::parse_timestamp( $item['started'] ?? null );
570 if ( null === $ts ) {
571 continue;
572 }
573
574 $diff = $ts - $target_ts;
575 $score = 0;
576 switch ( $match ) {
577 case 'on_or_after':
578 if ( $diff < 0 ) {
579 continue 2;
580 }
581 $score = $diff;
582 break;
583 case 'closest':
584 $score = abs( $diff );
585 break;
586 case 'on_or_before':
587 default:
588 if ( $diff > 0 ) {
589 continue 2;
590 }
591 $score = -$diff;
592 break;
593 }
594
595 if ( null === $best_score || $score < $best_score ) {
596 $best = $item;
597 $best_score = $score;
598 }
599 }
600
601 return $best;
602 }
603
604 /**
605 * Pull the activity-event array out of the W3C ActivityStreams envelope
606 * that `/activity/rewindable` returns. The endpoint puts the items in
607 * `current.orderedItems`; older proxy shapes used `orderedItems` at the
608 * top level, so check both before giving up.
609 *
610 * @param mixed $envelope Raw decoded response body.
611 * @return array|null
612 */
613 private static function extract_rewindable_items( $envelope ): ?array {
614 if ( ! is_array( $envelope ) && ! is_object( $envelope ) ) {
615 return null;
616 }
617 $envelope = (array) $envelope;
618 if ( isset( $envelope['current'] ) ) {
619 $current = (array) $envelope['current'];
620 if ( isset( $current['orderedItems'] ) && is_array( $current['orderedItems'] ) ) {
621 return $current['orderedItems'];
622 }
623 }
624 if ( isset( $envelope['orderedItems'] ) && is_array( $envelope['orderedItems'] ) ) {
625 return $envelope['orderedItems'];
626 }
627 return null;
628 }
629
630 /**
631 * Translate one /activity/rewindable event into the same backup-item
632 * shape `summarize_backup()` produces, so the ability's output schema
633 * stays stable across the upstream switch. Returns null for events that
634 * don't look like backups (no `rewind_id`).
635 *
636 * @param mixed $raw One element from `current.orderedItems`.
637 * @return array|null
638 */
639 private static function summarize_backup_event( $raw ): ?array {
640 if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
641 return null;
642 }
643 $raw = (array) $raw;
644
645 $rewind_id = $raw['rewind_id'] ?? null;
646 if ( null === $rewind_id || '' === $rewind_id ) {
647 return null;
648 }
649
650 $published = isset( $raw['published'] ) && is_string( $raw['published'] ) ? $raw['published'] : null;
651 $status_raw = isset( $raw['status'] ) && is_string( $raw['status'] ) ? $raw['status'] : null;
652 $name = isset( $raw['name'] ) && is_string( $raw['name'] ) ? $raw['name'] : '';
653 $is_rewindable = isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null;
654
655 return array(
656 'id' => (string) $rewind_id,
657 'started' => $published,
658 'last_updated' => $published,
659 'status' => self::map_event_status( $status_raw, $name ),
660 'period' => self::parse_timestamp( $rewind_id ),
661 'is_rewindable' => $is_rewindable,
662 'has_warnings' => self::event_has_warnings( $status_raw, $name ),
663 );
664 }
665
666 /**
667 * Map activity-event status / action-name to the status vocabulary the
668 * ability's output schema uses (the same labels as the old
669 * `/rewind/backups` endpoint: "finished", "error", ...). Falls back to
670 * the raw status when no mapping fits so the caller still sees signal.
671 *
672 * @param string|null $status_raw Activity event status (e.g. "success", "warning", "error").
673 * @param string $name Activity name, e.g. "rewind__backup_complete_full".
674 * @return string|null
675 */
676 private static function map_event_status( ?string $status_raw, string $name ): ?string {
677 if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
678 return 'finished';
679 }
680 return $status_raw;
681 }
682
683 /**
684 * Derive `has_warnings` from an activity event's status / name.
685 *
686 * @param string|null $status_raw Activity event status.
687 * @param string $name Activity name.
688 * @return bool|null
689 */
690 private static function event_has_warnings( ?string $status_raw, string $name ): ?bool {
691 if ( 'warning' === $status_raw ) {
692 return true;
693 }
694 if ( 'success' === $status_raw || false !== strpos( $name, 'backup_complete' ) ) {
695 return false;
696 }
697 return null;
698 }
699
700 /**
701 * Coerce an ISO 8601 string, RFC-style date string, or numeric unix
702 * timestamp to an int unix timestamp. Returns null for anything that
703 * can't be unambiguously parsed (instead of strtotime's `false`, which
704 * is also a valid timestamp for 1969-12-31).
705 *
706 * Fractional numeric strings (e.g. rewind_id "1778804242.107") are
707 * accepted — the fractional part is truncated.
708 *
709 * @param mixed $value Source value (string, int, float, or anything else).
710 * @return int|null
711 */
712 private static function parse_timestamp( $value ): ?int {
713 if ( is_int( $value ) ) {
714 return $value;
715 }
716 if ( is_float( $value ) ) {
717 return (int) $value;
718 }
719 if ( is_string( $value ) && '' !== $value ) {
720 if ( is_numeric( $value ) ) {
721 return (int) $value;
722 }
723 $ts = strtotime( $value );
724 return false === $ts ? null : $ts;
725 }
726 return null;
727 }
728
729 /**
730 * Enqueue an on-demand backup. Registration is gated on a Backup product
731 * being loaded so we assume one exists by the time this runs. Returns
732 * WP_Error only when the upstream connection itself fails so agents can
733 * retry strategically.
734 *
735 * @param mixed $input Unused; see note on execute_get_backup_overview().
736 * @return array|WP_Error
737 */
738 public static function execute_request_backup( $input = null ) {
739 unset( $input );
740
741 // wpcom can return HTTP 200 with `{ success: false, error: ... }`; treat
742 // that as a failure rather than reporting the backup was enqueued.
743 $result = self::unwrap_response( Jetpack_Backup::enqueue_backup() );
744 if ( ! is_array( $result ) || empty( $result['success'] ) ) {
745 return new WP_Error(
746 'jetpack_backup_data_unavailable',
747 __( 'The backup service did not accept the request. The connection to WordPress.com may be temporarily unavailable; retry shortly.', 'jetpack-backup-pkg' )
748 );
749 }
750
751 return array(
752 'enqueued' => true,
753 'message' => __( 'Backup enqueued. Use jetpack-backup/list-backups to monitor progress.', 'jetpack-backup-pkg' ),
754 );
755 }
756
757 /**
758 * Normalize a Jetpack_Backup helper result (WP_REST_Response, array, null,
759 * or WP_Error) to a plain value or null. Jetpack_Backup uses
760 * `rest_ensure_response()` on success and returns null on http failure, so
761 * abilities need both shapes flattened before summarising.
762 *
763 * @param mixed $maybe_response Result of a Jetpack_Backup helper call.
764 * @return mixed
765 */
766 private static function unwrap_response( $maybe_response ) {
767 if ( null === $maybe_response || is_wp_error( $maybe_response ) ) {
768 return null;
769 }
770 if ( $maybe_response instanceof WP_REST_Response ) {
771 return $maybe_response->get_data();
772 }
773 return $maybe_response;
774 }
775
776 /**
777 * Slice the (already-summarized) list down to a single id, or apply
778 * page/per_page pagination. Always returns the same item shape.
779 *
780 * @param array $items Summarized items.
781 * @param array $input Sanitized input.
782 * @return array
783 */
784 private static function apply_id_or_pagination( array $items, array $input ): array {
785 if ( isset( $input['id'] ) && is_string( $input['id'] ) && '' !== $input['id'] ) {
786 foreach ( $items as $item ) {
787 if ( isset( $item['id'] ) && (string) $item['id'] === $input['id'] ) {
788 return array( $item );
789 }
790 }
791 return array();
792 }
793
794 $page = max( 1, (int) ( $input['page'] ?? 1 ) );
795 $per_page = min( self::PER_PAGE_MAX, max( 1, (int) ( $input['per_page'] ?? self::PER_PAGE_DEFAULT ) ) );
796
797 return array_slice( $items, ( $page - 1 ) * $per_page, $per_page );
798 }
799
800 /**
801 * High-signal summary used inside `last_backup` for the overview. Same as
802 * `summarize_backup` minus the `started`/`period` fields which the agent
803 * doesn't need at a glance.
804 *
805 * @param mixed $raw One element from the upstream backups list.
806 * @return array|null
807 */
808 private static function summarize_last_backup( $raw ): ?array {
809 if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
810 return null;
811 }
812 return array_diff_key(
813 self::summarize_backup( $raw ),
814 array_flip( array( 'started', 'period' ) )
815 );
816 }
817
818 /**
819 * Summarize a `/rewind/backups` payload item using `rewind_id` as the
820 * canonical `id`. The numeric attempt id wpcom also exposes is
821 * internal to VaultPress and can't be looked up via any other endpoint,
822 * so it's intentionally dropped from the agent-facing shape — see the
823 * note on `$backup_item_schema` in `get_abilities()`.
824 *
825 * @param mixed $raw Upstream backup item.
826 * @return array
827 */
828 private static function summarize_backup( $raw ): array {
829 $raw = (array) $raw;
830 $rewind_id = $raw['rewind_id'] ?? null;
831 return array(
832 'id' => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
833 'started' => $raw['started'] ?? null,
834 'last_updated' => $raw['last_updated'] ?? null,
835 'status' => $raw['status'] ?? null,
836 'period' => $raw['period'] ?? null,
837 'is_rewindable' => isset( $raw['is_rewindable'] ) ? (bool) $raw['is_rewindable'] : null,
838 'has_warnings' => isset( $raw['has_warnings'] ) ? (bool) $raw['has_warnings'] : null,
839 );
840 }
841
842 /**
843 * Summarize a `/rewind/restores` payload item. `id` is the rewind_id
844 * of the backup being restored to — same canonical id system as
845 * `summarize_backup()`.
846 *
847 * @param mixed $raw Upstream restore item.
848 * @return array
849 */
850 private static function summarize_restore( $raw ): array {
851 $raw = (array) $raw;
852 $rewind_id = $raw['rewind_id'] ?? null;
853 return array(
854 'id' => ( null === $rewind_id || '' === $rewind_id ) ? null : (string) $rewind_id,
855 'started' => $raw['started'] ?? null,
856 'last_updated' => $raw['last_updated'] ?? null,
857 'status' => $raw['status'] ?? null,
858 'progress' => isset( $raw['progress'] ) ? (int) $raw['progress'] : null,
859 );
860 }
861
862 /**
863 * Summarize the wpcom schedule payload to `{ hour, minute }`.
864 *
865 * @param mixed $raw Upstream schedule payload.
866 * @return array|null
867 */
868 private static function summarize_schedule( $raw ): ?array {
869 if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
870 return null;
871 }
872 $raw = (array) $raw;
873 return array(
874 'hour' => isset( $raw['hour'] ) ? (int) $raw['hour'] : null,
875 'minute' => isset( $raw['minute'] ) ? (int) $raw['minute'] : null,
876 );
877 }
878
879 /**
880 * Maps both the production wpcom field names (`size_in_bytes`, `storage_limit_bytes`)
881 * and shorter aliases (`used_bytes`, `limit_bytes`) so the ability stays stable
882 * if the upstream payload is renamed.
883 *
884 * @param mixed $raw Upstream storage payload.
885 * @return array|null
886 */
887 private static function summarize_storage( $raw ): ?array {
888 if ( ! is_array( $raw ) && ! is_object( $raw ) ) {
889 return null;
890 }
891 $raw = (array) $raw;
892 $used_bytes = $raw['size_in_bytes'] ?? ( $raw['used_bytes'] ?? null );
893 $limit_bytes = $raw['storage_limit_bytes'] ?? ( $raw['limit_bytes'] ?? null );
894 return array(
895 'used_bytes' => null === $used_bytes ? null : (int) $used_bytes,
896 'limit_bytes' => null === $limit_bytes ? null : (int) $limit_bytes,
897 );
898 }
899 }
900