class-avcf-abilities-jetbackup-backups.php
2 days ago
class-avcf-abilities-jetbackup-destinations.php
2 weeks ago
class-avcf-abilities-jetbackup-jobs.php
2 weeks ago
class-avcf-abilities-jetbackup-queue.php
2 days ago
class-avcf-abilities-jetbackup-restore-point.php
2 days ago
class-avcf-abilities-jetbackup-restore.php
2 days ago
class-avcf-abilities-jetbackup-schedules.php
2 weeks ago
class-avcf-abilities-jetbackup-settings.php
2 weeks ago
class-avcf-abilities-jetbackup-system.php
2 weeks ago
class-avcf-jetbackup-detector.php
2 weeks ago
class-avcf-jetbackup-helpers.php
2 days ago
class-avcf-jetbackup-helpers.php
361 lines
| 1 | <?php |
| 2 | /** |
| 3 | * JetBackup — shared helpers for the atarim/jetbackup-* ability cluster. |
| 4 | * |
| 5 | * One invocation path for every ability: instantiate the JetBackup AJAX Call |
| 6 | * class, setData(), execute(), read the response — mirroring JetBackup's own |
| 7 | * JetBackup\Wordpress\Abilities::_execute(). Also holds the request field-name |
| 8 | * and enum constants so the ability files never reach into JetBackup internals. |
| 9 | * |
| 10 | * We deliberately bypass JetBackup's own "Abilities" toggle and MFA-for-abilities |
| 11 | * gate (those only guard JetBackup's own ability registration, not these direct |
| 12 | * AJAX-class calls); our gate is manage_options. Destructive abilities add a |
| 13 | * confirm:true guard on top. |
| 14 | * |
| 15 | * Built on JetBackup's AJAX Call classes (v3.1.23.x). Every call is guarded with |
| 16 | * class_exists and wrapped in try/catch, so a missing or renamed class degrades |
| 17 | * cleanly instead of fataling. Not runtime-tested here. |
| 18 | * |
| 19 | * @package atarim-visual-collaboration |
| 20 | */ |
| 21 | |
| 22 | if ( ! defined( 'ABSPATH' ) ) { |
| 23 | exit; |
| 24 | } |
| 25 | |
| 26 | class AVCF_JetBackup_Helpers { |
| 27 | |
| 28 | const PLUGIN_SLUG = 'jetbackup'; |
| 29 | const PLUGIN_LABEL = 'JetBackup'; |
| 30 | |
| 31 | // Request field names (mirror JetBackup constants so ability files stay decoupled). |
| 32 | const ID_FIELD = '_id'; // \JetBackup\JetBackup::ID_FIELD |
| 33 | const TYPE_FIELD = 'type'; // \JetBackup\Queue\QueueItem::TYPE |
| 34 | |
| 35 | // Pagination (\JetBackup\Wordpress\Abilities contract). |
| 36 | const KEY_SKIP = 'skip'; |
| 37 | const KEY_LIMIT = 'limit'; |
| 38 | const LIMIT_DEFAULT = 50; |
| 39 | const LIMIT_MAX = 100; |
| 40 | |
| 41 | // Queue types (\JetBackup\Queue\Queue::QUEUE_TYPE_*). |
| 42 | const QUEUE_BACKUP = 1; |
| 43 | const QUEUE_RESTORE = 2; |
| 44 | const QUEUE_DOWNLOAD = 4; |
| 45 | const QUEUE_DOWNLOAD_LOG = 5; |
| 46 | const QUEUE_REINDEX = 8; |
| 47 | const QUEUE_EXPORT = 64; |
| 48 | const QUEUE_EXTRACT = 128; |
| 49 | |
| 50 | // Restore option bitmask (\JetBackup\Queue\QueueItemRestore::OPTION_RESTORE_*). |
| 51 | const R_DB_ENTIRE = 1; // 1 << 0 import full db dump |
| 52 | const R_FILES_ENTIRE = 2; // 1 << 1 restore full homedir |
| 53 | const R_DB_EXCLUDE = 64; // 1 << 6 restore db minus selectedTables |
| 54 | const R_DB_SKIP = 128; // 1 << 7 skip database |
| 55 | const R_DB_INCLUDE = 256; // 1 << 8 restore only selectedTables |
| 56 | const R_FILES_EXCLUDE = 512; // 1 << 9 restore files minus folderList |
| 57 | const R_FILES_SKIP = 1024; // 1 << 10 skip files |
| 58 | const R_FILES_INCLUDE = 2048; // 1 << 11 restore only folderList |
| 59 | |
| 60 | // Keys redacted from get-system-info output (parity with JetBackup's own sanitiser). |
| 61 | const SYSTEM_INFO_REDACT = [ 'secured_dir', 'data_dir', 'wordpress_path', 'jetbackup_data_dir' ]; |
| 62 | |
| 63 | /** |
| 64 | * Is the JetBackup cluster usable right now? |
| 65 | */ |
| 66 | public static function is_available() { |
| 67 | return class_exists( 'AVCF_JetBackup_Detector' ) |
| 68 | && ( new AVCF_JetBackup_Detector() )->avcf_jetbackup_is_available(); |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * The single permission gate for every JetBackup ability. |
| 73 | */ |
| 74 | public static function can_manage() { |
| 75 | return function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ); |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Invoke a JetBackup AJAX Call by short class name (e.g. 'ListBackups', |
| 80 | * 'AddToQueue'). Returns a normalised array: [ success, message, data ]. |
| 81 | * |
| 82 | * @param string $call Short class name under \JetBackup\Ajax\Calls\. |
| 83 | * @param array $data Request payload keyed by JetBackup field names. |
| 84 | * @return array{success:bool,message:string,data:array} |
| 85 | */ |
| 86 | public static function invoke( $call, array $data = [] ) { |
| 87 | $fqcn = '\\JetBackup\\Ajax\\Calls\\' . $call; |
| 88 | |
| 89 | if ( ! class_exists( $fqcn ) ) { |
| 90 | return [ |
| 91 | 'success' => false, |
| 92 | 'message' => sprintf( 'JetBackup action "%s" is unavailable on this JetBackup version.', $call ), |
| 93 | 'data' => [], |
| 94 | ]; |
| 95 | } |
| 96 | |
| 97 | try { |
| 98 | $obj = new $fqcn(); |
| 99 | $obj->setData( $data ); |
| 100 | $obj->execute(); |
| 101 | |
| 102 | return [ |
| 103 | 'success' => true, |
| 104 | 'message' => (string) $obj->getResponseMessage(), |
| 105 | 'data' => (array) $obj->getResponseData(), |
| 106 | ]; |
| 107 | } catch ( \Throwable $e ) { |
| 108 | // JetBackup's AjaxException messages are the same user-facing validation |
| 109 | // strings its own UI shows (e.g. "No backup job id was provided"), so |
| 110 | // surfacing them helps the caller correct the request. |
| 111 | // |
| 112 | // Some of those messages are printf templates whose arguments live on |
| 113 | // the exception's getData() rather than in the message itself, e.g. |
| 114 | // AjaxException( 'Failed adding to queue. Error: %s', [ 'Already in queue' ] ). |
| 115 | // getMessage() alone would surface a bare "%s", dropping the reason, so |
| 116 | // interpolate the data into the template when both are present. Guarded |
| 117 | // so a placeholder/argument mismatch can never fatal: only attempt it |
| 118 | // when there is data and a placeholder, and fall back to the raw message. |
| 119 | $message = (string) $e->getMessage(); |
| 120 | if ( '' !== $message && method_exists( $e, 'getData' ) ) { |
| 121 | $args = $e->getData(); |
| 122 | if ( is_array( $args ) && ! empty( $args ) && false !== strpos( $message, '%' ) ) { |
| 123 | try { |
| 124 | $interpolated = vsprintf( $message, $args ); |
| 125 | if ( is_string( $interpolated ) && '' !== $interpolated ) { |
| 126 | $message = $interpolated; |
| 127 | } |
| 128 | } catch ( \Throwable $ignore ) { |
| 129 | // Template/argument mismatch — keep the raw message. |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | return [ |
| 134 | 'success' => false, |
| 135 | 'message' => '' !== $message ? $message : 'JetBackup action failed.', |
| 136 | 'data' => [], |
| 137 | ]; |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Map a friendly {id} input to JetBackup's _id request field, merging any extra |
| 143 | * pre-built fields. |
| 144 | */ |
| 145 | public static function id_payload( array $input, array $extra = [] ) { |
| 146 | $id = isset( $input['id'] ) ? (int) $input['id'] : 0; |
| 147 | return array_merge( [ self::ID_FIELD => $id ], $extra ); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Bound pagination inputs to JetBackup's skip/limit contract. |
| 152 | */ |
| 153 | public static function paginate( array $input ) { |
| 154 | $skip = isset( $input['skip'] ) ? max( 0, (int) $input['skip'] ) : 0; |
| 155 | $limit = isset( $input['limit'] ) ? (int) $input['limit'] : self::LIMIT_DEFAULT; |
| 156 | if ( $limit < 1 ) { |
| 157 | $limit = self::LIMIT_DEFAULT; |
| 158 | } |
| 159 | if ( $limit > self::LIMIT_MAX ) { |
| 160 | $limit = self::LIMIT_MAX; |
| 161 | } |
| 162 | return [ self::KEY_SKIP => $skip, self::KEY_LIMIT => $limit ]; |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Was an explicit confirm:true supplied? Destructive abilities require this. |
| 167 | */ |
| 168 | public static function confirmed( array $input ) { |
| 169 | return ! empty( $input['confirm'] ) && filter_var( $input['confirm'], FILTER_VALIDATE_BOOLEAN ); |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Standard "needs confirmation" response for destructive abilities. |
| 174 | */ |
| 175 | public static function confirm_required_response( $what ) { |
| 176 | return [ |
| 177 | 'success' => false, |
| 178 | 'message' => sprintf( 'This is a destructive action (%s). Re-run with confirm:true to proceed.', $what ), |
| 179 | 'data' => [], |
| 180 | ]; |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * Recursively strip redacted keys from get-system-info output. |
| 185 | */ |
| 186 | public static function redact_system_info( array $data ) { |
| 187 | foreach ( $data as $key => $value ) { |
| 188 | if ( in_array( $key, self::SYSTEM_INFO_REDACT, true ) ) { |
| 189 | unset( $data[ $key ] ); |
| 190 | continue; |
| 191 | } |
| 192 | if ( is_array( $value ) ) { |
| 193 | $data[ $key ] = self::redact_system_info( $value ); |
| 194 | } |
| 195 | } |
| 196 | return $data; |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Lift the created task's id to the top level so callers that start a task |
| 201 | * (e.g. run-backup) get an unmissable handle instead of digging JetBackup's |
| 202 | * internal _id out of the raw data. |
| 203 | * |
| 204 | * AddToQueue answers with the JOB id, not the id of the queue item it just |
| 205 | * created, so polling get-queue-item with it returns "Invalid queue item id |
| 206 | * provided". The job id is still useful, so it keeps its own field, and |
| 207 | * queue_item_id is resolved from the queue itself when $queue_type is given. |
| 208 | */ |
| 209 | public static function surface_queue_id( array $result, $queue_type = null ) { |
| 210 | if ( empty( $result['success'] ) || empty( $result['data'] ) || ! is_array( $result['data'] ) ) { |
| 211 | return $result; |
| 212 | } |
| 213 | |
| 214 | $jid = $result['data'][ self::ID_FIELD ] ?? ( $result['data']['id'] ?? null ); |
| 215 | |
| 216 | if ( null === $jid || '' === $jid ) { |
| 217 | return $result; |
| 218 | } |
| 219 | |
| 220 | $jid = is_numeric( $jid ) ? (int) $jid : $jid; |
| 221 | |
| 222 | $result['job_id'] = $jid; |
| 223 | $result['queue_item_id'] = ( null === $queue_type ) |
| 224 | ? $jid |
| 225 | : ( self::latest_queue_item_id( $queue_type ) ?? $jid ); |
| 226 | |
| 227 | return $result; |
| 228 | } |
| 229 | |
| 230 | /** |
| 231 | * Newest queue item of the given type, used to name the item AddToQueue just |
| 232 | * created. Returns null when the queue cannot be read, in which case the |
| 233 | * caller keeps the job id and list-queue-items remains the way through. |
| 234 | */ |
| 235 | private static function latest_queue_item_id( $queue_type ) { |
| 236 | $res = self::invoke( 'ListQueueItems', [ self::KEY_SKIP => 0, self::KEY_LIMIT => self::LIMIT_DEFAULT ] ); |
| 237 | |
| 238 | if ( empty( $res['success'] ) || empty( $res['data'] ) ) { |
| 239 | return null; |
| 240 | } |
| 241 | |
| 242 | $newest = null; |
| 243 | |
| 244 | foreach ( self::extract_snapshot_list( $res['data'] ) as $item ) { |
| 245 | if ( ! is_array( $item ) || (int) ( $item[ self::TYPE_FIELD ] ?? 0 ) !== (int) $queue_type ) { |
| 246 | continue; |
| 247 | } |
| 248 | |
| 249 | $id = $item[ self::ID_FIELD ] ?? ( $item['id'] ?? null ); |
| 250 | |
| 251 | if ( is_numeric( $id ) && ( null === $newest || (int) $id > $newest ) ) { |
| 252 | $newest = (int) $id; |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | return $newest; |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * Pull the array of snapshots out of a ListBackups response, tolerating the |
| 261 | * common wrapper shapes. |
| 262 | */ |
| 263 | private static function extract_snapshot_list( $data ) { |
| 264 | if ( ! is_array( $data ) ) { |
| 265 | return []; |
| 266 | } |
| 267 | if ( isset( $data[0] ) && is_array( $data[0] ) ) { |
| 268 | return $data; |
| 269 | } |
| 270 | foreach ( [ 'data', 'items', 'backups', 'snapshots', 'list' ] as $k ) { |
| 271 | if ( isset( $data[ $k ] ) && is_array( $data[ $k ] ) ) { |
| 272 | return $data[ $k ]; |
| 273 | } |
| 274 | } |
| 275 | return []; |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Resolve a snapshot NAME (as reported by a completed backup queue item's |
| 280 | * snapshot_name) to its numeric snapshot id, so restore-backup can accept a |
| 281 | * name directly and callers can skip a manual list-backups lookup. Searches |
| 282 | * the snapshot list in pages, capped, and returns null if not found. |
| 283 | */ |
| 284 | public static function resolve_snapshot_id_by_name( $name ) { |
| 285 | $name = (string) $name; |
| 286 | if ( '' === $name ) { |
| 287 | return null; |
| 288 | } |
| 289 | $skip = 0; |
| 290 | $limit = 100; |
| 291 | for ( $page = 0; $page < 10; $page++ ) { // cap the search (<= 1000 snapshots) |
| 292 | $res = self::invoke( 'ListBackups', [ self::KEY_SKIP => $skip, self::KEY_LIMIT => $limit ] ); |
| 293 | if ( empty( $res['success'] ) || empty( $res['data'] ) ) { |
| 294 | break; |
| 295 | } |
| 296 | $list = self::extract_snapshot_list( $res['data'] ); |
| 297 | if ( empty( $list ) ) { |
| 298 | break; |
| 299 | } |
| 300 | foreach ( $list as $snap ) { |
| 301 | if ( is_array( $snap ) && isset( $snap['name'] ) && (string) $snap['name'] === $name ) { |
| 302 | $id = $snap[ self::ID_FIELD ] ?? ( $snap['id'] ?? null ); |
| 303 | return ( null !== $id ) ? (int) $id : null; |
| 304 | } |
| 305 | } |
| 306 | if ( count( $list ) < $limit ) { |
| 307 | break; // last page |
| 308 | } |
| 309 | $skip += $limit; |
| 310 | } |
| 311 | return null; |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * Call an Atarim restore-point endpoint with this site's Atarim token. |
| 316 | * |
| 317 | * @param string $path Path under AVCF_CRM_API, e.g. 'v1/jetbackup/restore-point'. |
| 318 | * @param string $method 'GET' or 'POST'. |
| 319 | * @param array $body Body for POST. |
| 320 | * @return array {success:bool, message:string, data:array} |
| 321 | */ |
| 322 | public static function atarim_call( $path, $method = 'GET', $body = [] ) { |
| 323 | $functions = new AVCF_Functions(); |
| 324 | $token = $functions->avcf_get_setting_data( 'avc_atarim_secret_token' ); |
| 325 | |
| 326 | if ( empty( $token ) ) { |
| 327 | return [ 'success' => false, 'message' => 'This site is not connected to Atarim, so restore points cannot be tracked. Reconnect the site in the Atarim plugin settings.', 'data' => [] ]; |
| 328 | } |
| 329 | |
| 330 | $response = $functions->avcf_make_api_call( |
| 331 | rtrim( AVCF_CRM_API, '/' ) . '/' . ltrim( $path, '/' ), |
| 332 | $body, |
| 333 | '', |
| 334 | $token, |
| 335 | $method |
| 336 | ); |
| 337 | |
| 338 | $code = isset( $response['status_code'] ) ? (int) $response['status_code'] : 0; |
| 339 | |
| 340 | if ( $code === 401 ) { |
| 341 | return [ 'success' => false, 'message' => 'Atarim rejected this site\'s token. Reconnect the site in the Atarim plugin settings.', 'data' => [] ]; |
| 342 | } |
| 343 | |
| 344 | // avcf_make_api_call() only returns a decoded body on a 200; anything |
| 345 | // else arrives under 'error', so the endpoint's own message would be |
| 346 | // lost if we only ever read 'data'. |
| 347 | $payload = isset( $response['data'] ) && is_array( $response['data'] ) |
| 348 | ? $response['data'] |
| 349 | : ( isset( $response['error'] ) && is_array( $response['error'] ) ? $response['error'] : [] ); |
| 350 | |
| 351 | if ( empty( $payload ) ) { |
| 352 | return [ 'success' => false, 'message' => 'Atarim returned an unreadable response (HTTP ' . $code . ').', 'data' => [] ]; |
| 353 | } |
| 354 | |
| 355 | return [ |
| 356 | 'success' => ! empty( $payload['success'] ), |
| 357 | 'message' => isset( $payload['message'] ) ? (string) $payload['message'] : '', |
| 358 | 'data' => isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : [], |
| 359 | ]; |
| 360 | } |
| 361 | } |