File "class-ai1wm-rest-controller.php"
Full Path: /home/nokenter/public_html/controller/class-ai1wm-rest-controller.php
File size: 37.26 KB
MIME-type: text/x-php
Charset: utf-8
<?php
/**
* Copyright (C) 2014-2025 ServMask Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Attribution: This code is part of the All-in-One WP Migration plugin, developed by
*
* ███████╗███████╗██████╗ ██╗ ██╗███╗ ███╗ █████╗ ███████╗██╗ ██╗
* ██╔════╝██╔════╝██╔══██╗██║ ██║████╗ ████║██╔══██╗██╔════╝██║ ██╔╝
* ███████╗█████╗ ██████╔╝██║ ██║██╔████╔██║███████║███████╗█████╔╝
* ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║╚██╔╝██║██╔══██║╚════██║██╔═██╗
* ███████║███████╗██║ ██║ ╚████╔╝ ██║ ╚═╝ ██║██║ ██║███████║██║ ██╗
* ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
*/
if ( ! defined( 'ABSPATH' ) ) {
die( 'Kangaroos cannot jump here' );
}
class Ai1wm_Rest_Controller {
const REST_NAMESPACE = 'ai1wm/v1';
/**
* Register all REST API routes
*
* @return void
*/
public static function register_routes() {
// Regex bounds:
// job_id — ai1wm_storage_folder() emits uniqid() = 13 lowercase hex chars;
// allow {13,40} as a small safety margin for future changes.
// name — bound the overall match to 255 chars (common filesystem limit)
// including the .wpress suffix.
$job_id_path = '(?P<job_id>[a-f0-9]{13,40})';
$name_path = '(?P<name>[a-zA-Z0-9._-]{1,247}\.wpress)';
$secret_key_arg = array(
'secret_key' => array(
'type' => 'string',
'required' => false,
'description' => 'Shared-secret auth fallback for status/log polling after an import has invalidated the Application Password.',
),
);
$password_arg = array(
'password' => array(
'type' => 'string',
'required' => false,
'description' => 'Archive password. For exports: enables encryption. For imports: used to decrypt an encrypted archive.',
),
);
// Discovery
register_rest_route(
self::REST_NAMESPACE,
'/capabilities',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'capabilities' ),
'permission_callback' => array( __CLASS__, 'can_export' ),
)
);
// Export operations
register_rest_route(
self::REST_NAMESPACE,
'/exports',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'create_export' ),
'permission_callback' => array( __CLASS__, 'can_export' ),
'args' => array_merge(
$password_arg,
array(
'options' => array( 'type' => 'object', 'required' => false ),
'find_replace' => array( 'type' => 'array', 'required' => false ),
)
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/exports/' . $job_id_path,
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_export_status' ),
'permission_callback' => array( __CLASS__, 'can_poll_export' ),
'args' => $secret_key_arg,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/exports/' . $job_id_path . '/log',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_export_log' ),
'permission_callback' => array( __CLASS__, 'can_poll_export' ),
'args' => $secret_key_arg,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/exports/' . $job_id_path,
array(
'methods' => 'DELETE',
'callback' => array( __CLASS__, 'cancel_job' ),
'permission_callback' => array( __CLASS__, 'can_export' ),
)
);
// Import operations
register_rest_route(
self::REST_NAMESPACE,
'/imports',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'create_import' ),
'permission_callback' => array( __CLASS__, 'can_import' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/imports/' . $job_id_path . '/file',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'upload_import_file' ),
'permission_callback' => array( __CLASS__, 'can_upload_import_file' ),
'args' => array(
'auto_confirm' => array( 'type' => 'boolean', 'required' => false ),
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/imports/' . $job_id_path,
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_import_status' ),
'permission_callback' => array( __CLASS__, 'can_poll_import' ),
'args' => $secret_key_arg,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/imports/' . $job_id_path . '/confirm',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'confirm_import' ),
'permission_callback' => array( __CLASS__, 'can_import' ),
'args' => array_merge(
$password_arg,
array(
'proceed' => array( 'type' => 'boolean', 'required' => true ),
)
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/imports/' . $job_id_path . '/log',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_import_log' ),
'permission_callback' => array( __CLASS__, 'can_poll_import' ),
'args' => $secret_key_arg,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/imports/' . $job_id_path,
array(
'methods' => 'DELETE',
'callback' => array( __CLASS__, 'cancel_job' ),
'permission_callback' => array( __CLASS__, 'can_import' ),
)
);
// Backup management
register_rest_route(
self::REST_NAMESPACE,
'/backups',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'list_backups' ),
'permission_callback' => array( __CLASS__, 'can_export' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/backups/' . $name_path,
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_backup' ),
'permission_callback' => array( __CLASS__, 'can_export' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/backups/' . $name_path,
array(
'methods' => 'PATCH',
'callback' => array( __CLASS__, 'update_backup_label' ),
'permission_callback' => array( __CLASS__, 'can_import' ),
'args' => array(
'label' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => array( __CLASS__, 'validate_label_length' ),
),
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/backups/' . $name_path,
array(
'methods' => 'DELETE',
'callback' => array( __CLASS__, 'delete_backup' ),
'permission_callback' => array( __CLASS__, 'can_import' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/backups/' . $name_path . '/download',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'download_backup' ),
'permission_callback' => array( __CLASS__, 'can_export' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/backups/' . $name_path . '/restore',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'restore_backup' ),
'permission_callback' => array( __CLASS__, 'can_import' ),
)
);
}
// ── Permission callbacks ────────────────────────────────────────────
/**
* @return bool
*/
public static function can_export() {
return current_user_can( 'export' );
}
/**
* @return bool
*/
public static function can_import() {
return current_user_can( 'import' );
}
/**
* Poll/log endpoints: accept either the normal user capability OR a valid
* secret_key. Mirrors the wp_ajax_nopriv_ai1wm_status pattern so clients
* can keep polling after the import replaces the users table and the
* original Application Password stops authenticating.
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
public static function can_poll_export( $request ) {
return self::can_export() || self::has_valid_secret_key( $request );
}
/**
* @param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
public static function can_poll_import( $request ) {
return self::can_import() || self::has_valid_secret_key( $request );
}
/**
* Upload endpoint gate: require import capability AND that the job_id was
* issued by create_import for the current user (prevents cross-user job
* clobber). Returns a 404 WP_Error on unknown/foreign job ids rather than
* 403 — refusing to confirm whether another user's job exists.
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return bool|WP_Error
*/
public static function can_upload_import_file( $request ) {
if ( ! self::can_import() ) {
return false;
}
$job_id = $request->get_param( 'job_id' );
$issued_to = get_transient( 'ai1wm_rest_import_' . $job_id );
if ( $issued_to === false || (int) $issued_to !== get_current_user_id() ) {
return new WP_Error( 'not_found', __( 'Import job not found.', 'all-in-one-wp-migration' ), array( 'status' => 404 ) );
}
return true;
}
/**
* Cap backup label length at 255 chars to keep the wp_options row bounded.
*
* @param mixed $label
* @return bool|WP_Error
*/
public static function validate_label_length( $label ) {
if ( is_string( $label ) && strlen( $label ) <= 255 ) {
return true;
}
return new WP_Error( 'label_too_long', __( 'Label must be 255 characters or fewer.', 'all-in-one-wp-migration' ), array( 'status' => 400 ) );
}
/**
* @param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
private static function has_valid_secret_key( $request ) {
$provided = $request->get_param( 'secret_key' );
if ( empty( $provided ) || ! is_string( $provided ) ) {
return false;
}
try {
ai1wm_verify_secret_key( $provided );
return true;
} catch ( Ai1wm_Not_Valid_Secret_Key_Exception $e ) {
return false;
}
}
/**
* Clear WordPress's authentication error for read-only poll/log routes that
* carry a valid secret_key. Once an import replaces wp_users/wp_usermeta the
* client's Application Password (or login cookie) stops authenticating, and
* core rejects the request at the rest_authentication_errors layer before the
* route's permission_callback runs. Returning null lets the request reach
* can_poll_*, which re-checks the secret_key. Registered at priority 1000 so
* it runs after core's application-password (90) and cookie (100) handlers.
*
* @param WP_Error|null|true $result Current authentication result
* @return WP_Error|null|true
*/
public static function allow_secret_key_auth( $result ) {
// Only act once WordPress has already rejected authentication.
if ( ! is_wp_error( $result ) ) {
return $result;
}
// Poll/log endpoints are GET only.
if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || $_SERVER['REQUEST_METHOD'] !== 'GET' ) {
return $result;
}
// Limit to the import status and log routes. Export never replaces
// wp_users/wp_usermeta, so the client's credentials stay valid and this
// fallback is not needed there.
$route = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? $GLOBALS['wp']->query_vars['rest_route'] : '';
if ( ! preg_match( '#^/' . self::REST_NAMESPACE . '/imports/[a-f0-9]{13,40}(/log)?$#', $route ) ) {
return $result;
}
// Require a valid secret key supplied as a query parameter.
$provided = isset( $_GET['secret_key'] ) ? stripslashes_deep( $_GET['secret_key'] ) : '';
if ( ! is_string( $provided ) || $provided === '' ) {
return $result;
}
try {
ai1wm_verify_secret_key( $provided );
} catch ( Ai1wm_Not_Valid_Secret_Key_Exception $e ) {
return $result;
}
return null;
}
// ── Discovery ───────────────────────────────────────────────────────
/**
* GET /capabilities
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function capabilities( $request ) {
$capabilities = array(
'export' => current_user_can( 'export' ),
'import' => current_user_can( 'import' ),
'max_upload_size' => wp_max_upload_size(),
'max_upload_size_human' => size_format( wp_max_upload_size() ),
'wordpress_version' => get_bloginfo( 'version' ),
'php_version' => PHP_VERSION,
'plugin_version' => AI1WM_VERSION,
'site_url' => site_url(),
'storage_path_writable' => is_writable( AI1WM_STORAGE_PATH ),
'available_space' => ai1wm_disk_free_space( AI1WM_STORAGE_PATH ),
'available_space_human' => size_format( ai1wm_disk_free_space( AI1WM_STORAGE_PATH ) ),
'encryption_supported' => ai1wm_can_encrypt(),
);
return new WP_REST_Response( apply_filters( 'ai1wm_rest_capabilities', $capabilities ), 200 );
}
// ── Export operations ───────────────────────────────────────────────
/**
* POST /exports
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response|WP_Error
*/
public static function create_export( $request ) {
$params = self::build_export_params( $request );
try {
$params = Ai1wm_Export_Controller::export( $params );
} catch ( Exception $e ) {
return new WP_Error( 'export_failed', $e->getMessage(), array( 'status' => 500 ) );
}
$response = new WP_REST_Response(
array(
'job_id' => $params['storage'],
'status' => 'running',
'message' => __( 'Preparing export...', 'all-in-one-wp-migration' ),
'secret_key' => $params['secret_key'],
),
202
);
$response->header( 'Location', rest_url( self::REST_NAMESPACE . '/exports/' . $params['storage'] ) );
return $response;
}
/**
* GET /exports/{job_id}
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function get_export_status( $request ) {
return self::read_job_status( $request->get_param( 'job_id' ) );
}
/**
* GET /exports/{job_id}/log
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function get_export_log( $request ) {
return self::read_job_log( $request->get_param( 'job_id' ) );
}
// ── Import operations ───────────────────────────────────────────────
/**
* POST /imports
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function create_import( $request ) {
$job_id = ai1wm_storage_folder();
// Record that this job id was issued to the current user, so /file uploads
// can't target a job id belonging to another in-flight export/import.
// TTL auto-expires if the client never calls /file.
set_transient( 'ai1wm_rest_import_' . $job_id, get_current_user_id(), HOUR_IN_SECONDS );
return new WP_REST_Response(
array(
'job_id' => $job_id,
'status' => 'awaiting_upload',
),
201
);
}
/**
* POST /imports/{job_id}/file (multipart/form-data with upload_file field)
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response|WP_Error
*/
public static function upload_import_file( $request ) {
$job_id = $request->get_param( 'job_id' );
// Ownership already validated in can_upload_import_file; consume the
// one-shot transient so the job id cannot be reused.
delete_transient( 'ai1wm_rest_import_' . $job_id );
// Prefer the REST abstraction; fall back to $_FILES for WP versions /
// request paths where get_file_params() is unavailable or empty.
$files = $request->get_file_params();
$upload_file = isset( $files['upload_file'] ) ? $files['upload_file'] : ( isset( $_FILES['upload_file'] ) ? $_FILES['upload_file'] : null );
if ( ! is_array( $upload_file ) ) {
return new WP_Error(
'no_file',
__( 'No file attached. Use the "upload_file" multipart field.', 'all-in-one-wp-migration' ),
array( 'status' => 400 )
);
}
$upload_error = isset( $upload_file['error'] ) ? (int) $upload_file['error'] : UPLOAD_ERR_OK;
switch ( $upload_error ) {
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return new WP_Error( 'file_too_large', self::upload_limit_message(), array( 'status' => 413 ) );
case UPLOAD_ERR_NO_FILE:
return new WP_Error(
'no_file',
__( 'No file was uploaded.', 'all-in-one-wp-migration' ),
array( 'status' => 400 )
);
case UPLOAD_ERR_PARTIAL:
return new WP_Error(
'upload_incomplete',
__( 'The uploaded file was only partially received.', 'all-in-one-wp-migration' ),
array( 'status' => 400 )
);
case UPLOAD_ERR_NO_TMP_DIR:
case UPLOAD_ERR_CANT_WRITE:
case UPLOAD_ERR_EXTENSION:
default:
return new WP_Error(
'upload_failed',
__( 'The server could not accept the uploaded file. Check the server logs.', 'all-in-one-wp-migration' ),
array( 'status' => 500 )
);
}
if ( empty( $upload_file['tmp_name'] ) ) {
return new WP_Error(
'upload_failed',
__( 'The upload finished without producing a readable file.', 'all-in-one-wp-migration' ),
array( 'status' => 500 )
);
}
$params = array(
'storage' => $job_id,
'archive' => sanitize_file_name( $upload_file['name'] ),
);
if ( $request->get_param( 'auto_confirm' ) ) {
$params['ai1wm_confirmed'] = 1;
}
// Start import pipeline at priority 5 (Import_Upload handles validation + copy)
$params['priority'] = 5;
$params['secret_key'] = get_option( AI1WM_SECRET_KEY );
try {
$params = Ai1wm_Import_Controller::import( $params );
} catch ( Ai1wm_Upload_Exception $e ) {
$message = ( $e->getCode() === 413 ) ? self::upload_limit_message() : $e->getMessage();
return new WP_Error( 'upload_error', $message, array( 'status' => $e->getCode() ) );
} catch ( Ai1wm_Not_Decryptable_Exception $e ) {
// Check_Encryption halted the pipeline (encrypted archive, bad password, or no decrypt support).
// Storage preserved; client reads type/status from the status option.
return self::read_job_status( $job_id );
} catch ( Exception $e ) {
return new WP_Error( 'import_failed', $e->getMessage(), array( 'status' => 500 ) );
}
$response = new WP_REST_Response(
array(
'job_id' => $job_id,
'status' => 'running',
'message' => __( 'Importing...', 'all-in-one-wp-migration' ),
'secret_key' => $params['secret_key'],
),
202
);
$response->header( 'Location', rest_url( self::REST_NAMESPACE . '/imports/' . $job_id ) );
return $response;
}
/**
* GET /imports/{job_id}
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function get_import_status( $request ) {
return self::read_job_status( $request->get_param( 'job_id' ) );
}
/**
* POST /imports/{job_id}/confirm
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response|WP_Error
*/
public static function confirm_import( $request ) {
$job_id = $request->get_param( 'job_id' );
// Verify job is actually waiting for confirmation
$status = get_option( 'ai1wm_status_' . $job_id, array() );
$status_type = isset( $status['type'] ) ? $status['type'] : '';
if ( ! in_array( $status_type, array( 'confirm', 'disk_space_confirm', 'backup_is_encrypted' ), true ) ) {
return new WP_Error(
'conflict',
__( 'Import is not waiting for confirmation.', 'all-in-one-wp-migration' ),
array( 'status' => 409 )
);
}
if ( ! $request->has_param( 'proceed' ) ) {
return new WP_Error(
'missing_proceed',
__( 'The proceed parameter is required. Use DELETE /imports/{id} to cancel.', 'all-in-one-wp-migration' ),
array( 'status' => 400 )
);
}
if ( ! $request->get_param( 'proceed' ) ) {
// Explicit proceed=false — cancel the import
self::cleanup_job( $job_id );
return new WP_REST_Response(
array(
'job_id' => $job_id,
'status' => 'canceled',
),
200
);
}
$params = array(
'storage' => $job_id,
// Recover the archive name from status; the confirm halt dropped it from request params
'archive' => isset( $status['archive'] ) ? $status['archive'] : '',
'ai1wm_confirmed' => 1,
// Resume priority: encrypted backup resumes decrypt stage, others skip confirm
'priority' => ( $status_type === 'backup_is_encrypted' ) ? 75 : 150,
'secret_key' => get_option( AI1WM_SECRET_KEY ),
);
$password = $request->get_param( 'password' );
if ( ! empty( $password ) && is_string( $password ) ) {
$params['decryption_password'] = $password;
}
try {
Ai1wm_Import_Controller::import( $params );
} catch ( Ai1wm_Not_Decryptable_Exception $e ) {
// Wrong password (or still-missing one) on resume — status option already reflects it
return self::read_job_status( $job_id );
} catch ( Exception $e ) {
return new WP_Error( 'import_failed', $e->getMessage(), array( 'status' => 500 ) );
}
return new WP_REST_Response(
array(
'job_id' => $job_id,
'status' => 'running',
'message' => __( 'Importing...', 'all-in-one-wp-migration' ),
'secret_key' => $params['secret_key'],
),
200
);
}
/**
* GET /imports/{job_id}/log
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function get_import_log( $request ) {
return self::read_job_log( $request->get_param( 'job_id' ) );
}
// ── Shared job operations ───────────────────────────────────────────
/**
* DELETE /exports/{job_id} or /imports/{job_id}
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function cancel_job( $request ) {
$job_id = $request->get_param( 'job_id' );
// Write cancel marker to status
update_option(
'ai1wm_status_' . $job_id,
array(
'type' => 'canceled',
'title' => __( 'Canceled', 'all-in-one-wp-migration' ),
'message' => __( 'Operation canceled by REST API.', 'all-in-one-wp-migration' ),
),
false
);
// Clean up storage folder and log file
self::cleanup_job( $job_id );
return new WP_REST_Response(
array(
'job_id' => $job_id,
'status' => 'canceled',
),
200
);
}
// ── Backup management ───────────────────────────────────────────────
/**
* GET /backups
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response
*/
public static function list_backups( $request ) {
$backups = array();
$files = Ai1wm_Backups::get_files();
$labels = Ai1wm_Backups::get_labels();
$downloadable = Ai1wm_Backups::are_downloadable();
foreach ( $files as $file ) {
$backups[] = self::format_backup( $file, $labels, $downloadable );
}
return new WP_REST_Response( array( 'backups' => $backups ), 200 );
}
/**
* GET /backups/{name}
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response|WP_Error
*/
public static function get_backup( $request ) {
$name = $request->get_param( 'name' );
$backup_file = self::find_backup( $name );
if ( $backup_file === false ) {
return new WP_Error( 'not_found', __( 'Backup not found.', 'all-in-one-wp-migration' ), array( 'status' => 404 ) );
}
$backup = self::format_backup( $backup_file, Ai1wm_Backups::get_labels(), Ai1wm_Backups::are_downloadable() );
// Extract config metadata from archive
try {
$config = self::extract_backup_config( $name );
if ( $config !== false ) {
$backup['config'] = $config;
}
} catch ( Exception $e ) {
// Config extraction failed, continue without it
}
return new WP_REST_Response( $backup, 200 );
}
/**
* PATCH /backups/{name}
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response|WP_Error
*/
public static function update_backup_label( $request ) {
$name = $request->get_param( 'name' );
// Verify backup exists
if ( self::find_backup( $name ) === false ) {
return new WP_Error( 'not_found', __( 'Backup not found.', 'all-in-one-wp-migration' ), array( 'status' => 404 ) );
}
$label = $request->get_param( 'label' );
$label = ! empty( $label ) ? sanitize_text_field( $label ) : '';
if ( empty( $label ) ) {
Ai1wm_Backups::delete_label( $name );
} else {
Ai1wm_Backups::set_label( $name, $label );
}
return new WP_REST_Response(
array(
'name' => $name,
'label' => empty( $label ) ? '' : $label,
),
200
);
}
/**
* DELETE /backups/{name}
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response|WP_Error
*/
public static function delete_backup( $request ) {
$name = $request->get_param( 'name' );
// Verify backup exists
if ( self::find_backup( $name ) === false ) {
return new WP_Error( 'not_found', __( 'Backup not found.', 'all-in-one-wp-migration' ), array( 'status' => 404 ) );
}
try {
Ai1wm_Backups::delete_file( $name );
Ai1wm_Backups::delete_label( $name );
} catch ( Exception $e ) {
return new WP_Error( 'delete_failed', $e->getMessage(), array( 'status' => 500 ) );
}
return new WP_REST_Response( array( 'deleted' => true ), 200 );
}
/**
* GET /backups/{name}/download
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_REST_Response|WP_Error
*/
public static function download_backup( $request ) {
$name = $request->get_param( 'name' );
$backup_file = self::find_backup( $name );
if ( $backup_file === false ) {
return new WP_Error( 'not_found', __( 'Backup not found.', 'all-in-one-wp-migration' ), array( 'status' => 404 ) );
}
if ( ! Ai1wm_Backups::are_downloadable() ) {
return new WP_Error(
'not_downloadable',
__( 'Backups are stored outside the web root on this host and cannot be downloaded via URL.', 'all-in-one-wp-migration' ),
array( 'status' => 409 )
);
}
return new WP_REST_Response(
array(
'name' => $backup_file['filename'],
'download_url' => ai1wm_backup_url( array( 'archive' => $backup_file['filename'] ) ),
'size' => $backup_file['size'],
'size_human' => size_format( $backup_file['size'] ),
),
200
);
}
/**
* POST /backups/{name}/restore
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return WP_Error
*/
public static function restore_backup( $request ) {
return new WP_Error(
'upgrade_required',
sprintf(
/* translators: 1: Link to Unlimited Extension */
__(
'"Restore" functionality is available in our Unlimited Extension (%s). If you would rather go the manual route, you can still restore by downloading your backup and using the import endpoint.',
'all-in-one-wp-migration'
),
'https://servmask.com/products/unlimited-extension'
),
array( 'status' => 403 )
);
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Build export params from a REST request
*
* @param WP_REST_Request<array<string, mixed>> $request REST request
* @return array<string, mixed> Pipeline params
*/
private static function build_export_params( $request ) {
$params = array(
'secret_key' => get_option( AI1WM_SECRET_KEY ),
'options' => array(),
);
// Set export options (whitelist to prevent param injection)
$options = $request->get_param( 'options' );
if ( is_array( $options ) ) {
$allowed = apply_filters(
'ai1wm_rest_export_options_whitelist',
array(
'no_spam_comments',
'no_post_revisions',
'no_media',
'no_themes',
'no_inactive_themes',
'no_muplugins',
'no_plugins',
'no_inactive_plugins',
'no_cache',
'no_database',
'no_email_replace',
)
);
foreach ( $allowed as $key ) {
if ( isset( $options[ $key ] ) ) {
$params['options'][ $key ] = $options[ $key ];
}
}
}
// Set find and replace (pipeline expects columnar shape: old_value[] / new_value[])
$find_replace = $request->get_param( 'find_replace' );
if ( is_array( $find_replace ) ) {
$params['options']['replace'] = array( 'old_value' => array(), 'new_value' => array() );
foreach ( $find_replace as $pair ) {
if ( isset( $pair['find'], $pair['replace'] ) && is_string( $pair['find'] ) && is_string( $pair['replace'] ) ) {
$params['options']['replace']['old_value'][] = $pair['find'];
$params['options']['replace']['new_value'][] = $pair['replace'];
}
}
}
// Set archive password — pipeline gates encryption on encrypt_backups + encrypt_password
$password = $request->get_param( 'password' );
if ( ! empty( $password ) && is_string( $password ) ) {
$params['options']['encrypt_backups'] = true;
$params['options']['encrypt_password'] = $password;
}
// Apply premium plugin filters
$params = apply_filters( 'ai1wm_rest_export_params', $params );
return $params;
}
/**
* Read job-scoped status and augment for REST response
*
* @param string $job_id Job ID (storage folder name)
* @return WP_REST_Response
*/
private static function read_job_status( $job_id ) {
$data = get_option( 'ai1wm_status_' . $job_id, array() );
// No status yet (race condition between POST and first loopback write)
if ( empty( $data ) ) {
return new WP_REST_Response(
array(
'job_id' => $job_id,
'type' => 'progress',
'status' => 'running',
'percent' => 0,
'message' => __( 'Preparing...', 'all-in-one-wp-migration' ),
),
200
);
}
// Augment with job_id
$data['job_id'] = $job_id;
// Derive simplified status from type
$type = isset( $data['type'] ) ? $data['type'] : '';
switch ( $type ) {
case 'progress':
case 'info':
$data['status'] = 'running';
break;
case 'confirm':
case 'disk_space_confirm':
case 'backup_is_encrypted':
$data['status'] = 'confirm';
break;
case 'done':
case 'download':
$data['status'] = 'complete';
break;
case 'error':
case 'server_cannot_decrypt':
$data['status'] = 'error';
break;
case 'canceled':
$data['status'] = 'canceled';
break;
case 'blogs':
$data['status'] = 'error';
$data['message'] = __( 'Multisite blog selection is not supported via the REST API. Please use the admin interface.', 'all-in-one-wp-migration' );
break;
default:
$data['status'] = 'running';
break;
}
// Augment confirm type with archive_info from package.json
if ( $type === 'confirm' ) {
$archive_info = self::read_archive_info( $job_id );
if ( $archive_info !== false ) {
$data['archive_info'] = $archive_info;
}
}
return new WP_REST_Response( $data, 200 );
}
/**
* Read job error log
*
* @param string $job_id Job ID (storage folder name)
* @return WP_REST_Response
*/
private static function read_job_log( $job_id ) {
$log_path = ai1wm_error_path( $job_id );
$log = '';
if ( file_exists( $log_path ) ) {
$log = file_get_contents( $log_path );
}
return new WP_REST_Response(
array(
'job_id' => $job_id,
'log' => $log,
),
200
);
}
/**
* Clean up a job's storage folder and log file
*
* @param string $job_id Job ID (storage folder name)
* @return void
*/
private static function cleanup_job( $job_id ) {
$params = array( 'storage' => $job_id );
try {
Ai1wm_Directory::delete( ai1wm_storage_path( $params ) );
} catch ( Exception $e ) {
// Storage may already be deleted
}
// Delete log file
$log_path = ai1wm_error_path( $job_id );
if ( file_exists( $log_path ) ) {
ai1wm_unlink( $log_path );
}
delete_transient( 'ai1wm_rest_import_' . $job_id );
}
/**
* Format a backup file for REST response
*
* @param array<string, mixed> $file Backup file from Ai1wm_Backups::get_files()
* @param array<string, string> $labels Labels from Ai1wm_Backups::get_labels()
* @param boolean $downloadable Whether direct download URLs are available
* @return array<string, mixed>
*/
private static function format_backup( $file, $labels, $downloadable ) {
$backup = array(
'name' => $file['filename'],
'size' => $file['size'],
'size_human' => size_format( $file['size'] ),
'created_at' => gmdate( 'Y-m-d\TH:i:s\Z', $file['mtime'] ),
'label' => isset( $labels[ $file['filename'] ] ) ? $labels[ $file['filename'] ] : '',
'downloadable' => $downloadable,
);
if ( $downloadable ) {
$backup['download_url'] = ai1wm_backup_url( array( 'archive' => $file['filename'] ) );
}
return $backup;
}
/**
* Find a backup file by name
*
* @param string $name Backup filename
* @return array<string, mixed>|false Backup file info or false
*/
private static function find_backup( $name ) {
$files = Ai1wm_Backups::get_files();
foreach ( $files as $file ) {
if ( $file['filename'] === $name ) {
return $file;
}
}
return false;
}
/**
* Extract config metadata from backup's package.json
*
* @param string $name Backup filename
* @return array<string, mixed>|false Config data or false
*/
private static function extract_backup_config( $name ) {
$params = array( 'archive' => $name, 'storage' => ai1wm_storage_folder() );
$storage = ai1wm_storage_path( $params );
try {
$archive = new Ai1wm_Extractor( ai1wm_backup_path( $params ) );
$archive->extract_by_files_array( $storage, array( AI1WM_PACKAGE_NAME ) );
$archive->close();
$config = self::parse_package( $storage . DIRECTORY_SEPARATOR . AI1WM_PACKAGE_NAME );
Ai1wm_Directory::delete( $storage );
return $config;
} catch ( Exception $e ) {
try {
Ai1wm_Directory::delete( $storage );
} catch ( Exception $e2 ) {
// Ignore
}
}
return false;
}
/**
* Read archive_info from the job's storage folder (package.json extracted by pipeline)
*
* @param string $job_id Job ID
* @return array<string, mixed>|false
*/
private static function read_archive_info( $job_id ) {
$params = array( 'storage' => $job_id );
return self::parse_package( ai1wm_storage_path( $params ) . DIRECTORY_SEPARATOR . AI1WM_PACKAGE_NAME );
}
/**
* Parse package.json file into config array
*
* @param string $path Path to package.json
* @return array<string, mixed>|false
*/
private static function parse_package( $path ) {
if ( ! file_exists( $path ) ) {
return false;
}
$contents = file_get_contents( $path );
if ( $contents === false ) {
return false;
}
$package = json_decode( $contents, true );
if ( ! is_array( $package ) ) {
return false;
}
return array(
'wordpress_version' => isset( $package['WordPress']['Version'] ) ? $package['WordPress']['Version'] : null,
'php_version' => isset( $package['PHP']['Version'] ) ? $package['PHP']['Version'] : null,
'plugin_version' => isset( $package['Plugin']['Version'] ) ? $package['Plugin']['Version'] : null,
'site_url' => isset( $package['SiteURL'] ) ? $package['SiteURL'] : null,
'encrypted' => ! empty( $package['Encrypted'] ),
);
}
/**
* Upload limit exceeded message (matches browser UI copy)
*
* @return string
*/
private static function upload_limit_message() {
return sprintf(
/* translators: 1: Max upload file size, 2: Link to Unlimited Extension, 3: Link to how to article */
__(
'Your file exceeds the %1$s upload limit set by your host. Our Unlimited Extension bypasses this! %2$s. If you prefer a manual fix, follow our step-by-step guide on raising your upload limit: %3$s',
'all-in-one-wp-migration'
),
ai1wm_size_format( wp_max_upload_size() ),
'https://servmask.com/products/unlimited-extension',
'https://help.servmask.com/2018/10/27/how-to-increase-maximum-upload-file-size-in-wordpress/'
);
}
}