$code, 'message' => $message, ), $extra ), $status ); } /** * Send JSON, close the connection, then keep working (create/status/cancel/resume). * * @param array $data Success payload. * @param callable $after Work to run after the response. */ private static function respond_then( array $data, $after ) { $body = wp_json_encode( array( 'success' => true, 'data' => $data, ) ); ignore_user_abort( true ); if ( ! headers_sent() ) { header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) ); header( 'Content-Length: ' . strlen( $body ) ); header( 'Connection: close' ); } while ( ob_get_level() ) { ob_end_clean(); } echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped flush(); Environment::finish_request(); call_user_func( $after ); exit; } /** * Scope from the request. * * @return array */ private static function scope_from_request() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. $input = array( 'type' => isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : 'full', 'blog_id' => isset( $_POST['blog_id'] ) ? absint( $_POST['blog_id'] ) : 0, 'include_users' => ! empty( $_POST['include_users'] ), 'convert_prefix' => ! empty( $_POST['convert_prefix'] ), 'tables' => isset( $_POST['tables'] ) ? array_map( 'strval', (array) wp_unslash( $_POST['tables'] ) ) : array(), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- names are matched against the catalog, never used raw. ); // phpcs:enable return Table_Catalog::normalize_scope( $input ); } /** * Environment warnings shown in the dialog. * * @return array */ private static function environment() { $mode = Environment::run_mode(); return array( 'mode' => $mode, 'loopback' => Environment::loopback_works(), 'cron' => Environment::cron_enabled(), 'engine' => Environment::db_engine(), ); } /** * dbmg_preflight (§13, §11.3). */ public static function preflight() { self::guard(); if ( 'unsupported' === Environment::db_engine() ) { self::error( 'unsupported_db', Errors::message( 'unsupported_db' ) . ' ' . Errors::fix( 'unsupported_db' ) ); } try { $tables = Table_Catalog::resolve( self::scope_from_request() ); } catch ( Backup_Exception $e ) { self::error( $e->get_error_code(), Errors::message( $e->get_error_code(), $e->get_context() ) . ' ' . Errors::fix( $e->get_error_code() ) ); } $pre = Diskspace::preflight( $tables, Job::default_part_size() ); wp_send_json_success( array_merge( $pre, self::environment(), array( 'labels' => Admin_Page::preflight_labels( $pre ) ) ) ); } /** * dbmg_tables: picker groups and sites (Advanced options). */ public static function tables() { self::guard(); $sites = array(); if ( is_multisite() ) { foreach ( get_sites( array( 'number' => 500, 'orderby' => 'id', 'deleted' => 0, ) ) as $site ) { $sites[] = array( 'id' => (int) $site->blog_id, 'label' => untrailingslashit( $site->domain . $site->path ), ); } } wp_send_json_success( array( 'groups' => Table_Catalog::picker_groups(), 'sites' => $sites, 'multisite' => is_multisite(), 'more' => is_multisite() && get_sites( array( 'count' => true ) ) > 500, ) ); } /** * dbmg_create (§13): re-check server-side, create the job, answer immediately, then start it. */ public static function create() { self::guard(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : ''; $name = function_exists( 'mb_substr' ) ? mb_substr( $name, 0, 120 ) : substr( $name, 0, 120 ); if ( 'unsupported' === Environment::db_engine() ) { self::error( 'unsupported_db', Errors::message( 'unsupported_db' ) ); } if ( ! Lock::acquire( 60 ) ) { self::error( 'job_running', Errors::message( 'job_running' ), 409 ); } try { $existing = Job::load(); if ( $existing && $existing->is_active() ) { Lock::release(); self::error( 'job_running', Errors::message( 'job_running' ), 409 ); } $scope = self::scope_from_request(); $tables = Table_Catalog::resolve( $scope ); $pre = Diskspace::preflight( $tables, Job::default_part_size() ); if ( 'insufficient' === $pre['status'] ) { Lock::release(); $ctx = array( 'needs' => size_format( $pre['required'], 1 ), 'free' => null === $pre['free'] ? '?' : size_format( $pre['free'], 1 ), ); self::error( 'insufficient_space', Errors::message( 'insufficient_space', $ctx ) . ' ' . Errors::fix( 'insufficient_space' ), 400, array( 'preflight' => $pre ) ); } $mode = Environment::run_mode(); $job = Job::create( $scope, $tables, $name, get_current_user_id(), $mode ); } catch ( Backup_Exception $e ) { Lock::release(); self::error( $e->get_error_code(), Errors::message( $e->get_error_code(), $e->get_context() ) . ' ' . Errors::fix( $e->get_error_code(), $e->get_context() ) ); } Lock::release(); $snapshot = $job->snapshot(); self::respond_then( array( 'job' => $snapshot, 'job_html' => Admin_Page::job_rows_html( $snapshot ), ), static function () { // Browser mode: the page's polls drive the job (Runner::nudge ticks immediately). Runner::kickoff(); } ); } /** * Full UI state: job snapshot + backup rows + storage panel. * * @return array */ public static function state() { $job = Job::load(); $snap = ( $job && $job->is_active() ) ? $job->snapshot() : null; $rows = array(); foreach ( Storage::manifests() as $m ) { $html = Admin_Page::row_html( $m ); $rows[] = array( 'id' => (int) $m['id'], 'status' => $m['status'], 'hash' => md5( $html ), 'html' => $html, 'bytes' => (int) $m['zip_bytes'], ); } return array( 'job' => $snap, 'job_html' => $snap ? Admin_Page::job_rows_html( $snap ) : '', 'backups' => $rows, 'storage_html' => Admin_Page::storage_html(), 'server_time' => time(), ); } /** * dbmg_status (§13): state, then nudge the job if it hasn't moved (§8.2 C). */ public static function status() { self::guard(); $state = self::state(); if ( ! $state['job'] ) { // This page already told the user the backup finished: don't repeat it on the next screen. delete_user_meta( get_current_user_id(), Notices::META ); wp_send_json_success( $state ); } self::respond_then( $state, static function () { Runner::nudge( 'poll' ); } ); } /** * dbmg_list (§13). */ public static function list_backups() { self::guard(); wp_send_json_success( self::state() ); } /** * dbmg_cancel (§8.6). */ public static function cancel() { self::guard(); $job = Job::load(); if ( ! $job || ! $job->is_active() ) { wp_send_json_success( self::state() ); } Options::set( Job::CANCEL_OPTION, array( 'id' => (int) $job->s['id'], 'user' => get_current_user_id(), ) ); $snap = $job->snapshot(); $snap['cancelling'] = true; self::respond_then( array( 'job' => $snap ), static function () { // Nothing running (paused or between ticks)? Process the cancel right here. Runner::run_tick( 'browser', true, false ); } ); } /** * dbmg_resume (§8.5). */ public static function resume() { self::guard(); $job = Job::load(); if ( ! $job || 'stalled' !== $job->s['stage'] ) { wp_send_json_success( self::state() ); } self::respond_then( array( 'resumed' => true ), static function () { Runner::resume(); } ); } /** * dbmg_delete: ids[] (§13). Refuses the active job's files. */ public static function delete() { self::guard(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. $ids = isset( $_POST['ids'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['ids'] ) ) : array(); $job = Job::load(); $active = ( $job && $job->is_active() ) ? (int) $job->s['id'] : 0; $done = array(); foreach ( $ids as $id ) { if ( $id <= 0 || $id === $active ) { continue; } if ( Storage::delete( $id ) ) { $done[] = $id; } } if ( ! $done && $ids ) { self::error( 'not_found', __( 'That backup was already deleted or is still running. Refresh the list and try again.', 'database-export-genie' ), 404 ); } wp_send_json_success( array_merge( self::state(), array( 'deleted' => $done ) ) ); } /** * dbmg_delete_all: confirm=DELETE (§13, §14.5). */ public static function delete_all() { self::guard(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. $confirm = isset( $_POST['confirm'] ) ? sanitize_text_field( wp_unslash( $_POST['confirm'] ) ) : ''; if ( 'DELETE' !== $confirm ) { self::error( 'confirm', __( 'Type DELETE to confirm.', 'database-export-genie' ) ); } $job = Job::load(); if ( $job && $job->is_active() ) { self::error( 'job_running', __( 'A backup is running. Cancel it first, then delete all backups.', 'database-export-genie' ), 409 ); } Storage::delete_all(); wp_send_json_success( self::state() ); } /** * dbmg_setting: the "delete backups on uninstall" checkbox (§18). */ public static function setting() { self::guard(); // Only the fields sent are changed, so one control never resets another. $saved = array(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. if ( isset( $_POST['delete_on_uninstall'] ) ) { $saved['delete_on_uninstall'] = ! empty( $_POST['delete_on_uninstall'] ); Options::set( 'dbmg_delete_on_uninstall', $saved['delete_on_uninstall'] ? 1 : 0 ); } if ( ! empty( $_POST['hide_public_warning'] ) ) { // One-way: the warning box is removed permanently (D33). Options::set( 'dbmg_hide_public_warning', 1 ); $saved['hide_public_warning'] = true; } // phpcs:enable WordPress.Security.NonceVerification.Missing wp_send_json_success( $saved ); } /** * dbmg_tick: trigger A. Nonce (made for the user and session that sent the loopback, D73) plus the rotating * one-time key, which stands in for a capability check: only the site itself can know it. */ public static function tick() { nocache_headers(); if ( ! check_ajax_referer( Runner::LOOPBACK_NONCE, 'nonce', false ) ) { wp_send_json_error( array( 'code' => 'bad_nonce' ), 403 ); } $key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : ''; if ( ! Runner::key_valid( $key ) ) { wp_send_json_error( array( 'code' => 'forbidden' ), 403 ); } Options::delete( Runner::KEY_OPTION ); // One use only. self::respond_then( array( 'accepted' => true ), static function () { Runner::run_tick( 'loopback' ); } ); } /** * dbmg_ping: loopback self-test (nonce, then a short-lived random token; D73). */ public static function ping() { nocache_headers(); if ( ! check_ajax_referer( Runner::LOOPBACK_NONCE, 'nonce', false ) ) { wp_die( '', '', array( 'response' => 403 ) ); } $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; $stored = Options::get( 'dbmg_ping' ); if ( is_string( $stored ) && '' !== $token && hash_equals( $stored, $token ) ) { wp_die( 'dbmg-pong', '', array( 'response' => 200 ) ); } wp_die( '', '', array( 'response' => 403 ) ); } }