← 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 | +} | |