503 ) ); } /** * Cron pump: advance whatever job is active — pending uploads first, then a * (new or in-progress) scheduled backup. Self-reschedules until done. * * @return void */ public function pump() { $gw = new Job_Gateway(); $upload = $gw->active( 'upload' ); if ( $upload ) { $this->run_job( 'upload', (int) $upload['id'] ); // "Send to both" queues TWO independent upload jobs (one per destination). // run_job() only re-arms the pump while ITS job is unfinished, so once this one // completes the second destination's job would be stranded with no continuation. // Re-arm if any other upload (or a backup) is still pending. (run_job already // scheduled the event when it hit the time budget — the guard avoids doubling.) if ( ( $gw->active( 'upload' ) || $gw->active( 'backup' ) ) && ! wp_next_scheduled( 'aisiteark_job_continue' ) ) { wp_schedule_single_event( time() + 5, 'aisiteark_job_continue' ); } return; } $backup = $gw->active( 'backup' ); if ( $backup ) { $this->run_job( 'backup', (int) $backup['id'] ); } // No active job: nothing to advance. STARTING an automatic backup is a Pro concern — // the Pro scheduler creates the job on its cron trigger and hands off to this pump via // the aisiteark_job_continue event. See includes/pro/class-scheduling.php. } /** * Step a job to completion within a wall-clock budget; reschedule if not done. * * @param string $type backup|upload. * @param int $id Job id. * @return void */ private function run_job( $type, $id ) { $t0 = microtime( true ); $res = array(); do { $res = ( 'upload' === $type ) ? Upload_Runner::step( $id ) : Backup_Runner::step( $id ); } while ( empty( $res['done'] ) && empty( $res['error'] ) && ( microtime( true ) - $t0 ) < 45 ); if ( empty( $res['done'] ) && empty( $res['error'] ) ) { wp_schedule_single_event( time() + 30, 'aisiteark_job_continue' ); } // M1: alert on a failed unattended job (scheduled/auto backups run silently). if ( ! empty( $res['error'] ) ) { $why = isset( $res['message'] ) ? (string) $res['message'] : ''; /* translators: 1: job type, 2: error message. */ self::log_activity( sprintf( __( '%1$s failed: %2$s', 'ai-siteark' ), ucfirst( $type ), $why ) ); /** * An unattended backup/upload failed. The Pro add-on's failure notifications * (email + webhook) hook here; the free plugin only logs the failure above. * * @param string $type Job type (backup|upload). * @param string $why Error message. */ do_action( 'aisiteark_backup_failed', $type, $why ); } } /** * OAuth return: claim tokens from the broker and set the destination. * * @return void */ public function handle_cloud_callback() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_cloud_cb' ); // Off-site destinations are Pro — never store OAuth tokens on a Free plan, // even if the connect URL was crafted or the plan lapsed mid-flow. if ( ! Plan::can( 'cloud' ) ) { wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-settings', 'tab' => 'destinations', 'msg' => 'cloud_pro' ), admin_url( 'admin.php' ) ) ); exit; } // phpcs:disable WordPress.Security.NonceVerification.Recommended $provider = isset( $_GET['provider'] ) ? sanitize_key( wp_unslash( $_GET['provider'] ) ) : ''; $grant = isset( $_GET['grant'] ) ? sanitize_text_field( wp_unslash( $_GET['grant'] ) ) : ''; $err = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended // The broker relays the provider's own error (e.g. access_denied, // redirect_uri_mismatch, an AADSTS code) here. Surface it verbatim so the // admin can see *why* Google/Microsoft/Dropbox refused, instead of a generic // "please try again" that hides console-config problems (see // docs/CLOUD-OAUTH-TROUBLESHOOTING.md). if ( '' !== $err ) { set_transient( 'aisiteark_cloud_error_detail', substr( $err, 0, 200 ), 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-settings', 'tab' => 'destinations', 'msg' => 'cloud_error' ), admin_url( 'admin.php' ) ) ); exit; } $msg = 'cloud_error'; if ( in_array( $provider, array( 'google', 'microsoft', 'dropbox' ), true ) && $grant ) { $response = wp_remote_post( Backup_Sync::broker() . '/calendar/claim', array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'provider' => $provider, 'grant' => $grant, 'site' => home_url() ) ), ) ); if ( ! is_wp_error( $response ) ) { $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( is_array( $data ) && ! empty( $data['refresh_token'] ) ) { Backup_Sync::store_tokens( $provider, array( 'refresh_token' => sanitize_text_field( $data['refresh_token'] ), 'access_token' => isset( $data['access_token'] ) ? sanitize_text_field( $data['access_token'] ) : '', 'expires' => time() + ( isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600 ), ) ); $s = Settings::get(); $s['destination'] = $provider; Settings::save( $s ); /* translators: %s: provider (google/microsoft/dropbox). */ self::log_activity( sprintf( __( 'Cloud destination connected: %s', 'ai-siteark' ), $provider ) ); $msg = 'cloud_connected'; } } } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-settings', 'msg' => $msg ), admin_url( 'admin.php' ) ) ); exit; } /** * Disconnect an OAuth destination. * * @return void */ public function handle_cloud_disconnect() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_cloud_disconnect' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. $provider = isset( $_REQUEST['provider'] ) ? sanitize_key( wp_unslash( $_REQUEST['provider'] ) ) : ''; if ( in_array( $provider, array( 'google', 'microsoft', 'dropbox' ), true ) ) { Backup_Sync::disconnect( $provider ); $s = Settings::get(); if ( ( $s['destination'] ?? '' ) === $provider ) { $s['destination'] = ''; Settings::save( $s ); } /* translators: %s: provider. */ self::log_activity( sprintf( __( 'Cloud destination disconnected: %s', 'ai-siteark' ), $provider ) ); } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-settings', 'msg' => 'cloud_disconnected' ), admin_url( 'admin.php' ) ) ); exit; } /** * Handle license activation: free registration (email → code → verify) and * direct key activation (free or Pro), plus deactivate / check-for-updates. * Mirrors the AI-Inquiro / AI-MailBridge flow. * * @return void */ public function handle_license() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_save_license' ); $client = new License_Client(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. $do = isset( $_POST['license_action'] ) ? sanitize_key( wp_unslash( $_POST['license_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. $key = isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) : ''; $msg = 'license_error'; if ( 'request_code' === $do ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above. $email = isset( $_POST['license_email'] ) ? sanitize_email( wp_unslash( $_POST['license_email'] ) ) : ''; $name = isset( $_POST['license_name'] ) ? sanitize_text_field( wp_unslash( $_POST['license_name'] ) ) : ''; $newsletter = ! empty( $_POST['license_newsletter'] ); $terms = ! empty( $_POST['license_terms'] ); // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( ! is_email( $email ) ) { $msg = 'license_email'; } elseif ( ! $terms ) { $msg = 'license_terms'; } else { $res = $client->request_code( $email, $name, $newsletter ); $msg = $res['ok'] ? 'code_sent' : ( 'terms_required' === $res['error'] ? 'license_terms' : 'license_error' ); } } elseif ( 'verify_code' === $do ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above. $email = isset( $_POST['license_email'] ) ? sanitize_email( wp_unslash( $_POST['license_email'] ) ) : ''; $otp = isset( $_POST['license_otp'] ) ? preg_replace( '/[^0-9]/', '', sanitize_text_field( wp_unslash( $_POST['license_otp'] ) ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $msg = $client->verify_code( $email, $otp ) ? 'license_active' : 'code_invalid'; } elseif ( 'activate' === $do ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. $terms = ! empty( $_POST['license_terms'] ); if ( ! $terms ) { $msg = 'license_terms'; } elseif ( '' === $key ) { $msg = 'license_error'; } else { $res = $client->activate( $key ); if ( ! empty( $res['ok'] ) ) { $msg = 'license_active'; } elseif ( false !== stripos( (string) $res['error'], 'seat' ) || false !== stripos( (string) $res['error'], 'limit' ) ) { $msg = 'license_seats'; } else { $msg = 'license_error'; } } } elseif ( 'deactivate' === $do ) { $client->deactivate(); $msg = 'license_inactive'; } elseif ( 'check' === $do ) { $client->check_now(); $msg = 'license_checked'; } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-settings', 'msg' => $msg ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase F1 — receive one chunk of an uploaded .aisv archive so a backup taken * elsewhere (or downloaded) can be restored here. Chunks append to a temp file * in the backups dir; the first chunk must carry the AISV magic, and the final * chunk finalizes to a restorable filename. ADMIN-only + nonce. * * @return void */ public function ajax_upload_chunk() { check_ajax_referer( 'aisiteark_ajax', 'nonce' ); if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-siteark' ) ) ); } // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- nonce checked above; uid reduced to [a-z0-9], index/total cast to int. $uid = isset( $_POST['uid'] ) ? preg_replace( '/[^a-z0-9]/', '', (string) wp_unslash( $_POST['uid'] ) ) : ''; $index = isset( $_POST['index'] ) ? max( 0, (int) $_POST['index'] ) : 0; $total = isset( $_POST['total'] ) ? max( 1, (int) $_POST['total'] ) : 1; // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput if ( strlen( $uid ) < 8 ) { wp_send_json_error( array( 'message' => __( 'Invalid upload session.', 'ai-siteark' ) ) ); } // Rate-limit new upload sessions only (first chunk) — not every chunk. if ( 0 === $index && ! Rate_Limit::allow( 'upload_session', 30, 10 * MINUTE_IN_SECONDS ) ) { wp_send_json_error( array( 'message' => Rate_Limit::message() ) ); } // A chunk rejected for exceeding the host's upload limit arrives with an error code // and an empty tmp_name — report THAT rather than a vague "no chunk", since it is // the usual cause of a stuck upload on a tight host. $err = isset( $_FILES['chunk']['error'] ) ? (int) $_FILES['chunk']['error'] : UPLOAD_ERR_NO_FILE; if ( UPLOAD_ERR_INI_SIZE === $err || UPLOAD_ERR_FORM_SIZE === $err ) { wp_send_json_error( array( 'message' => sprintf( /* translators: %s: PHP upload_max_filesize value. */ __( 'This server rejected the upload chunk as too large (upload_max_filesize is %s). Ask your host to raise upload_max_filesize and post_max_size.', 'ai-siteark' ), (string) ini_get( 'upload_max_filesize' ) ), ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- $_FILES tmp_name is a server-set path, validated by is_uploaded_file(). if ( empty( $_FILES['chunk']['tmp_name'] ) || ! is_uploaded_file( $_FILES['chunk']['tmp_name'] ) ) { wp_send_json_error( array( 'message' => __( 'No chunk received.', 'ai-siteark' ) ) ); } $dir = Local_Store::dir(); if ( is_wp_error( $dir ) || ! $dir ) { wp_send_json_error( array( 'message' => __( 'Backup folder is not available.', 'ai-siteark' ) ) ); } $part = trailingslashit( $dir ) . 'upload-' . $uid . '.aisv.part'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- local temp chunk, validated by is_uploaded_file() above. $data = file_get_contents( $_FILES['chunk']['tmp_name'] ); if ( false === $data ) { wp_send_json_error( array( 'message' => __( 'Could not read the uploaded chunk.', 'ai-siteark' ) ) ); } // Accept a raw .aisv, or a single .gz / .zip wrapping one — decided from the very // first chunk's leading bytes, so a wrong file is rejected before the whole upload. if ( 0 === $index && ! Upload_Unwrap::is_accepted_head( (string) $data ) ) { wp_send_json_error( array( 'message' => __( 'That file is not an AI-SiteArk (.aisv) backup, nor a .zip or .gz containing one.', 'ai-siteark' ) ) ); } // Write the chunk at its exact BYTE OFFSET rather than blind-appending. This makes a // re-sent chunk idempotent (a retry after a timeout writes the same bytes in the same // place, never duplicating), which is what lets a multi-gigabyte upload survive a network // hiccup and retry instead of corrupting the archive or restarting from zero. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- nonce checked above; cast to int. $offset = isset( $_POST['offset'] ) ? max( 0, (int) $_POST['offset'] ) : ( $index * strlen( (string) $data ) ); $fp = @fopen( $part, 'c+b' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged if ( ! $fp ) { wp_send_json_error( array( 'message' => __( 'Could not write the upload. Check folder permissions.', 'ai-siteark' ) ) ); } if ( 0 === $index ) { ftruncate( $fp, 0 ); // Fresh start — also cleanly re-starts a retried first chunk. } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite if ( 0 !== fseek( $fp, $offset ) || false === fwrite( $fp, $data ) ) { fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose wp_send_json_error( array( 'message' => __( 'Could not write the upload chunk to disk.', 'ai-siteark' ) ) ); } fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( $index + 1 < $total ) { wp_send_json_success( array( 'done' => false, 'received' => $index + 1, 'total' => $total ) ); } // Final chunk — verify the whole file actually assembled before restoring a truncated one. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- nonce checked above; cast to int. $expected = isset( $_POST['filesize'] ) ? max( 0, (int) $_POST['filesize'] ) : 0; clearstatcache(); $have = (int) @filesize( $part ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $expected > 0 && $have !== $expected ) { // A gap slipped through — do NOT finalize a truncated archive. The client retries. wp_send_json_error( array( 'message' => __( 'The upload finished with missing data — please retry.', 'ai-siteark' ), 'incomplete' => true, 'have' => $have, ) ); } // Assembled. A raw .aisv finalizes instantly (a rename); a .gz/.zip wrapper is // handed to the separate finalize step, which decompresses with its own time // budget so this last chunk POST never blocks on a multi-GB extraction. if ( 'aisv' === Upload_Unwrap::sniff( $part ) ) { $final_name = 'uploaded-' . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 4, false ) . '.aisv'; $final_path = trailingslashit( $dir ) . $final_name; if ( ! @rename( $part, $final_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename wp_delete_file( $part ); wp_send_json_error( array( 'message' => __( 'Could not finalize the uploaded backup.', 'ai-siteark' ) ) ); } wp_send_json_success( array( 'done' => true, 'file' => $final_name ) ); } // Compressed wrapper — leave the .part in place for aisiteark_upload_finalize. wp_send_json_success( array( 'done' => true, 'unwrap' => true, 'uid' => $uid ) ); } /** * Finish a chunked upload whose assembled file is a .gz / .zip wrapper: decompress / * extract the inner .aisv (streamed, with its own time budget) and hand off a * restorable filename. ADMIN + AJAX nonce. Idempotent — a retried call after success * returns the same finalized file rather than failing. * * @return void */ public function ajax_upload_finalize() { check_ajax_referer( 'aisiteark_ajax', 'nonce' ); if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-siteark' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- nonce checked above; uid reduced to [a-z0-9]. $uid = isset( $_POST['uid'] ) ? preg_replace( '/[^a-z0-9]/', '', (string) wp_unslash( $_POST['uid'] ) ) : ''; if ( strlen( $uid ) < 8 ) { wp_send_json_error( array( 'message' => __( 'Invalid upload session.', 'ai-siteark' ) ) ); } $dir = Local_Store::dir(); if ( is_wp_error( $dir ) || ! $dir ) { wp_send_json_error( array( 'message' => __( 'Backup folder is not available.', 'ai-siteark' ) ) ); } // A retried finalize (the decompression outlived the browser's patience and it // called again) must not redo the work or error — return the file we already made. $done_key = 'aisiteark_upl_' . $uid; $prev = get_transient( $done_key ); if ( $prev && is_file( trailingslashit( $dir ) . $prev ) ) { wp_send_json_success( array( 'file' => $prev ) ); } $part = trailingslashit( $dir ) . 'upload-' . $uid . '.aisv.part'; if ( ! is_file( $part ) ) { wp_send_json_error( array( 'message' => __( 'That upload could not be found — please upload the file again.', 'ai-siteark' ) ) ); } @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a multi-GB decompression can exceed the default limit. $final_name = 'uploaded-' . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 4, false ) . '.aisv'; $res = Upload_Unwrap::to_aisv( $part, trailingslashit( $dir ) . $final_name ); if ( is_wp_error( $res ) ) { if ( is_file( $part ) ) { wp_delete_file( $part ); } wp_send_json_error( array( 'message' => $res->get_error_message() ) ); } set_transient( $done_key, $final_name, HOUR_IN_SECONDS ); wp_send_json_success( array( 'file' => $final_name ) ); } /** * Running-task controls (cancel / pause / resume) from the Activity page. * ADMIN + nonce. * * @return void */ public function handle_task() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_task' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; $type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : ''; $job = isset( $_POST['job'] ) ? (int) $_POST['job'] : 0; // phpcs:enable WordPress.Security.NonceVerification.Missing $label = ''; if ( 'restore' === $type ) { if ( 'cancel' === $op ) { Restore_Runner::cancel(); $label = __( 'Restore cancelled', 'ai-siteark' ); } } elseif ( 'backup' === $type && $job ) { if ( 'cancel' === $op ) { Backup_Runner::cancel( $job ); $label = __( 'Backup cancelled', 'ai-siteark' ); } elseif ( 'pause' === $op ) { Backup_Runner::pause( $job ); $label = __( 'Backup paused', 'ai-siteark' ); } elseif ( 'resume' === $op ) { $label = Backup_Runner::resume( $job ) ? __( 'Backup resumed', 'ai-siteark' ) : __( 'Could not resume — another backup is running', 'ai-siteark' ); } } elseif ( 'upload' === $type && $job ) { if ( 'cancel' === $op ) { Upload_Runner::cancel( $job ); $label = __( 'Upload cancelled', 'ai-siteark' ); } elseif ( 'pause' === $op ) { Upload_Runner::pause( $job ); $label = __( 'Upload paused', 'ai-siteark' ); } elseif ( 'resume' === $op ) { Upload_Runner::resume( $job ); $label = __( 'Upload resumed', 'ai-siteark' ); } } if ( '' !== $label ) { self::log_activity( $label ); } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-activity', 'msg' => 'task_done' ), admin_url( 'admin.php' ) ) ); exit; } /** * Clear the activity log. ADMIN + nonce. * * @return void */ public function handle_clear_log() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_clear_log' ); delete_option( 'aisiteark_activity' ); self::log_activity( __( 'Activity log cleared', 'ai-siteark' ) ); wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-activity', 'msg' => 'log_cleared' ), admin_url( 'admin.php' ) ) ); exit; } /** * Clear a stuck / abandoned restore so backups aren't blocked. ADMIN + nonce. * * @return void */ public function handle_cancel_restore() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_cancel_restore' ); Restore_Runner::cancel(); self::log_activity( __( 'Restore cancelled / cleared', 'ai-siteark' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. $back = isset( $_REQUEST['from'] ) && 'restore' === $_REQUEST['from'] ? 'ai-siteark-restore' : 'ai-siteark'; wp_safe_redirect( add_query_arg( array( 'page' => $back, 'msg' => 'restore_cancelled' ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase F2 — fetch a .aisv archive from a URL (e.g. a cloud share link) into * the backups dir and hand off to restore. Streamed to disk, magic-verified. * * @return void */ /** * Stream the standalone recovery installer (tools/aisv-installer.php) to the browser, * so an admin can save it and carry it to a dead server alongside a `.aisv` backup. * * @return void */ public function handle_download_installer() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_download_installer' ); // Shipped as .txt so it is never web-executable inside the plugin directory; the // operator saves the download as aisv-installer.php on the recovery server. $path = AISITEARK_DIR . 'tools/aisv-installer.txt'; if ( ! is_readable( $path ) ) { wp_die( esc_html__( 'The recovery installer file is missing from the plugin.', 'ai-siteark' ) ); } nocache_headers(); header( 'Content-Type: application/octet-stream' ); header( 'Content-Disposition: attachment; filename="aisv-installer.php"' ); header( 'Content-Length: ' . (string) filesize( $path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile readfile( $path ); exit; } /** * P3: pull a backup down from a connected cloud destination and hand it to the * restore flow — no manual download-and-re-upload. * * The filename is not trusted: only a name the adapter's own list_files() reports is * accepted, so a crafted request can't make us fetch an arbitrary remote path. The * download streams to a `.part` file, is magic-verified, then atomically renamed into * the backups directory under a fresh name. * * @return void */ public function handle_restore_from_cloud() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_restore_cloud' ); if ( ! Plan::can( 'cloud' ) ) { wp_die( esc_html__( 'Off-site destinations are a Pro feature.', 'ai-siteark' ) ); } if ( ! Rate_Limit::allow( 'cloud_fetch', 10, 10 * MINUTE_IN_SECONDS ) ) { wp_die( esc_html( Rate_Limit::message() ) ); } // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. $key = isset( $_POST['destination'] ) ? sanitize_key( wp_unslash( $_POST['destination'] ) ) : ''; $name = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing $fail = static function ( $msg ) { wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-restore', 'msg' => $msg ), admin_url( 'admin.php' ) ) ); exit; }; $provider = Backup_Sync::provider( $key ); if ( ! $provider || ! $provider->is_connected() ) { $fail( 'cloud_error' ); } $dir = Local_Store::dir(); if ( is_wp_error( $dir ) || ! $dir ) { $fail( 'cloud_error' ); } // Only accept a name the destination actually reports — never a caller-supplied path. $listing = $provider->list_files(); if ( is_wp_error( $listing ) ) { $fail( 'cloud_error' ); } if ( ! in_array( $name, wp_list_pluck( (array) $listing, 'name' ), true ) ) { $fail( 'cloud_missing' ); } $tmp = trailingslashit( $dir ) . 'cloudfetch-' . wp_generate_password( 8, false ) . '.aisv.part'; $res = $provider->download( $name, $tmp ); if ( is_wp_error( $res ) ) { if ( is_file( $tmp ) ) { wp_delete_file( $tmp ); } $fail( 'cloud_fetch' ); } // Confirm it really is one of our archives before offering to restore it. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $fh = fopen( $tmp, 'rb' ); $magic = $fh ? fread( $fh, 6 ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- reads the 6-byte .aisv magic. if ( $fh ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } if ( "AISV1\n" !== $magic ) { wp_delete_file( $tmp ); $fail( 'cloud_notaisv' ); } $final = 'cloud-' . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 4, false ) . '.aisv'; if ( ! @rename( $tmp, trailingslashit( $dir ) . $final ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename wp_delete_file( $tmp ); $fail( 'cloud_error' ); } self::log_activity( sprintf( /* translators: 1: filename, 2: destination key. */ __( 'Downloaded %1$s from %2$s for restore.', 'ai-siteark' ), $name, $key ) ); wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-restore', 'file' => rawurlencode( $final ) ), admin_url( 'admin.php' ) ) ); exit; } public function handle_restore_from_url() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_restore_url' ); if ( ! Rate_Limit::allow( 'url_fetch', 10, 10 * MINUTE_IN_SECONDS ) ) { wp_die( esc_html( Rate_Limit::message() ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. $url = isset( $_POST['aisiteark_url'] ) ? esc_url_raw( wp_unslash( $_POST['aisiteark_url'] ) ) : ''; $msg = 'url_error'; $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); $host = (string) wp_parse_url( $url, PHP_URL_HOST ); $dir = Local_Store::dir(); if ( '' !== $url && in_array( $scheme, array( 'http', 'https' ), true ) && ! self::is_internal_host( $host ) && ! is_wp_error( $dir ) && $dir ) { $tmp = trailingslashit( $dir ) . 'urlfetch-' . wp_generate_password( 8, false ) . '.aisv.part'; // DNS-rebinding defence: is_internal_host() vetted the host's IPs, but a hostile DNS // server could return a different (internal) address when cURL re-resolves a moment // later (TOCTOU). Pin the connection to a vetted public IP so cURL never re-resolves. // Only for a hostname (a literal-IP URL connects to that IP directly, nothing to pin) // and only over cURL; the streams transport falls back to the unpinned — but still // host-vetted — fetch. $pin_cb = self::pin_fetch_ip( strtolower( trim( (string) $host, '[]' ) ), $scheme ); $res = wp_remote_get( $url, array( 'timeout' => 180, 'stream' => true, 'filename' => $tmp, // Do NOT follow redirects: the SSRF guard only vetted the ORIGINAL host, and // a public URL that 3xx-redirects to 169.254.169.254 (or any internal host) // would otherwise sail straight through. A legitimate backup URL should point // directly at the file. 'redirection' => 0, ) ); if ( $pin_cb ) { remove_action( 'http_api_curl', $pin_cb ); } if ( ! is_wp_error( $res ) && 200 === (int) wp_remote_retrieve_response_code( $res ) && is_file( $tmp ) ) { // The URL may point at a raw .aisv or a .gz/.zip wrapping one — normalise // (streamed, magic-verified, scanned) exactly as an uploaded file is. @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a fetched .gz/.zip may need a multi-GB decompress. $final = 'uploaded-' . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 4, false ) . '.aisv'; $norm = Upload_Unwrap::to_aisv( $tmp, trailingslashit( $dir ) . $final ); if ( ! is_wp_error( $norm ) ) { wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-restore', 'file' => rawurlencode( $final ) ), admin_url( 'admin.php' ) ) ); exit; } $msg = ( 'aisiteark_not_container' === $norm->get_error_code() || false !== strpos( $norm->get_error_code(), 'notaisv' ) ) ? 'url_notaisv' : 'url_fetch'; } else { $msg = 'url_fetch'; } if ( is_file( $tmp ) ) { wp_delete_file( $tmp ); } } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-restore', 'msg' => $msg ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase G4 — verify a backup's integrity on demand against its checksum sidecar. * * @return void */ public function handle_verify() { if ( ! current_user_can( Capabilities::MANAGE ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_verify' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. $file = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : ''; $path = $file ? Local_Store::path_for( $file ) : false; $msg = 'verify_none'; if ( $path ) { $res = self::verify_volumes( $path ); $msg = ( 'ok' === $res ) ? 'verify_ok' : ( 'bad' === $res ? 'verify_bad' : 'verify_none' ); } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark', 'msg' => $msg ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase G4 — verify every stored backup's integrity in one pass. * * @return void */ public function handle_verify_all() { if ( ! current_user_can( Capabilities::MANAGE ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_verify_all' ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $files = (array) $wpdb->get_col( "SELECT filename FROM {$wpdb->prefix}aisiteark_backups ORDER BY id DESC" ); $ok = 0; $bad = 0; $none = 0; foreach ( $files as $file ) { $path = Local_Store::path_for( $file ); if ( ! $path ) { continue; } $res = self::verify_volumes( $path ); if ( 'ok' === $res ) { $ok++; } elseif ( 'bad' === $res ) { $bad++; } else { $none++; } } set_transient( 'aisiteark_verify_all_result', sprintf( /* translators: 1: passed, 2: failed, 3: no-checksum. */ __( 'Integrity check: %1$d passed, %2$d failed, %3$d without a checksum.', 'ai-siteark' ), $ok, $bad, $none ), 60 ); wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark', 'msg' => 'verify_all' ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase G4 — write a SHA-256 integrity checksum sidecar next to a finished * archive, so restore can detect corruption or tampering of the file. * * @param string $archive_path Absolute path of the finished archive. * @return void */ public function write_checksum( $archive_path ) { if ( ! is_string( $archive_path ) || ! is_file( $archive_path ) ) { return; } // G2: checksum every volume of the archive (each gets its own .sha256). The backup runner's // finalize already writes these one volume per step (resumably), so skip any volume that // already has a sidecar — this hook must not re-read the whole (multi-GB) archive and 504. // Also skip a single volume too large to hash in one pass (same budget finalize uses). $max_hash = (int) apply_filters( 'aisiteark_max_hash_bytes', 3 * 1024 * 1024 * 1024 ); foreach ( Local_Store::volumes_for( $archive_path ) as $vol ) { if ( is_file( $vol . '.sha256' ) ) { continue; // Already hashed (by finalize or a prior run). } if ( $max_hash > 0 && (int) filesize( $vol ) > $max_hash ) { continue; // Too large to hash safely in one request. } $hash = hash_file( 'sha256', $vol ); if ( $hash ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents @file_put_contents( $vol . '.sha256', $hash ); } } } /** * Verify every volume of an archive against its checksum sidecar. * * @param string $base Base archive path. * @return string 'ok' (all match), 'bad' (a mismatch), or 'none' (no sidecars). */ public static function verify_volumes( $base ) { $vols = Local_Store::volumes_for( $base ); $seen = false; foreach ( $vols as $vol ) { $sum = $vol . '.sha256'; if ( ! is_file( $sum ) ) { continue; } $seen = true; $expected = trim( (string) file_get_contents( $sum ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents if ( '' === $expected || ! hash_equals( $expected, (string) hash_file( 'sha256', $vol ) ) ) { return 'bad'; } } return $seen ? 'ok' : 'none'; } /** * Phase M1 — send a test report email to the configured address. * * @return void */ public function handle_test_email() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_test_email' ); $to = trim( (string) ( Settings::get()['notify_email'] ?? '' ) ); $ok = false; if ( is_email( $to ) ) { $ok = wp_mail( $to, esc_html__( 'AI-SiteArk test email', 'ai-siteark' ), sprintf( /* translators: %s: site URL. */ esc_html__( 'This is a test email from AI-SiteArk on %s. Backup reports will be sent to this address.', 'ai-siteark' ), home_url() ) ); } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-settings', 'tab' => 'general', 'msg' => $ok ? 'test_sent' : 'test_failed' ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase M3 — append a line to the activity log (capped, option-backed). * * @param string $message Event message. * @return void */ public static function log_activity( $message ) { $log = get_option( 'aisiteark_activity', array() ); if ( ! is_array( $log ) ) { $log = array(); } // Record WHO did it: the logged-in user, or system/cron for unattended jobs. $uid = get_current_user_id(); $who = __( 'System / cron', 'ai-siteark' ); if ( $uid ) { $user = wp_get_current_user(); $who = ( $user && $user->exists() ) ? ( $user->display_name ? $user->display_name : $user->user_login ) : ( '#' . $uid ); } elseif ( defined( 'WP_CLI' ) && WP_CLI ) { $who = 'WP-CLI'; } array_unshift( $log, array( 'time' => current_time( 'mysql' ), 'msg' => (string) $message, 'user' => (string) $who, 'uid' => (int) $uid, 'ip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', ) ); $log = array_slice( $log, 0, 300 ); // Keep the most recent 300 for the audit trail. update_option( 'aisiteark_activity', $log, false ); } /** * Phase M1 — email a short report when a backup finishes, if a notification * address is configured. Fires on both manual and scheduled backups. * * @param string $archive_path Absolute path of the finished archive. * @return void */ public function notify_backup_done( $archive_path ) { $file_for_log = basename( (string) $archive_path ); self::log_activity( sprintf( /* translators: %s: backup filename. */ __( 'Backup created: %s', 'ai-siteark' ), $file_for_log ) ); /** * A backup finished successfully. The Pro add-on's notifications (email + Slack / * Teams / Discord webhook) hook here. The free plugin only records the activity-log * entry above. See includes/pro/ and docs/FREE-PRO-SPLIT.md. * * @param string $archive_path Absolute path of the finished archive. */ do_action( 'aisiteark_backup_finished', $archive_path ); } /** * Phase M4 — save a short note/label for a backup (stored in an option map, * keyed by filename, so no schema change). MANAGE + nonce. * * @return void */ public function handle_save_note() { if ( ! current_user_can( Capabilities::MANAGE ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_save_note' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. $file = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : ''; $note = isset( $_POST['note'] ) ? sanitize_text_field( wp_unslash( $_POST['note'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( '' !== $file ) { $notes = get_option( 'aisiteark_backup_notes', array() ); if ( ! is_array( $notes ) ) { $notes = array(); } if ( '' === $note ) { unset( $notes[ $file ] ); } else { $notes[ $file ] = mb_substr( $note, 0, 200 ); } update_option( 'aisiteark_backup_notes', $notes, false ); } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark', 'msg' => 'note_saved' ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase G3 — database maintenance: OPTIMIZE / CHECK / REPAIR every table with * this site's prefix (scoped, so multisite/other apps are untouched). * ADMIN + nonce; results shown via a transient notice. * * @return void */ public function handle_db_maint() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_db_maint' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. $op = isset( $_POST['op'] ) ? strtoupper( sanitize_key( wp_unslash( $_POST['op'] ) ) ) : ''; $map = array( 'OPTIMIZE' => 'OPTIMIZE', 'CHECK' => 'CHECK', 'REPAIR' => 'REPAIR' ); if ( ! isset( $map[ $op ] ) ) { wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark', 'msg' => 'db_error' ), admin_url( 'admin.php' ) ) ); exit; } global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery $tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix ) . '%' ) ); $done = 0; foreach ( (array) $tables as $t ) { $safe = '`' . str_replace( '`', '``', (string) $t ) . '`'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $op is whitelisted (OPTIMIZE/CHECK/REPAIR); table name backtick-escaped. $wpdb->query( $op . ' TABLE ' . $safe ); $done++; } set_transient( 'aisiteark_db_maint_result', sprintf( /* translators: 1: operation, 2: table count. */ __( '%1$s complete on %2$d tables.', 'ai-siteark' ), ucfirst( strtolower( $op ) ), (int) $done ), 60 ); wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark', 'msg' => 'db_done' ), admin_url( 'admin.php' ) ) ); exit; } /** * Phase F4 — extract and stream a single file out of a backup archive. * ADMIN + nonce; scans the archive to the matching entry and streams it * (decrypting with the supplied password when the backup is encrypted). * * @return void */ public function handle_download_file() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_download_file' ); if ( ! Rate_Limit::allow( 'download', 60, 10 * MINUTE_IN_SECONDS ) ) { wp_die( esc_html( Rate_Limit::message() ) ); } // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked above; password must be preserved verbatim for decryption. $file = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : ''; $entry = isset( $_POST['entry'] ) ? sanitize_text_field( wp_unslash( $_POST['entry'] ) ) : ''; $password = isset( $_POST['password'] ) ? (string) wp_unslash( $_POST['password'] ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $path = $file ? Local_Store::path_for( $file ) : false; if ( ! $path || '' === $entry ) { wp_die( esc_html__( 'File not found in the backup.', 'ai-siteark' ) ); } $reader = new \AtoZ\SiteArk\Archive\Archive_Reader(); $ok = $reader->open( $path ); if ( is_wp_error( $ok ) ) { wp_die( esc_html( $ok->get_error_message() ) ); } $header = $reader->next(); $manifest = ( 'manifest' === ( isset( $header['type'] ) ? $header['type'] : '' ) ) ? json_decode( $reader->read_payload(), true ) : array(); if ( \AtoZ\SiteArk\Archive\Manifest::is_encrypted( $manifest ) ) { $key = \AtoZ\SiteArk\Archive\Manifest::verify_password( $manifest, $password ); if ( false === $key ) { $reader->close(); wp_die( esc_html__( 'This backup is encrypted — the password is missing or wrong.', 'ai-siteark' ) ); } $reader->set_key( $key ); } while ( null !== ( $h = $reader->next() ) ) { if ( 'file' === ( isset( $h['type'] ) ? $h['type'] : '' ) && ( isset( $h['path'] ) ? $h['path'] : '' ) === $entry ) { nocache_headers(); header( 'Content-Type: application/octet-stream' ); header( 'Content-Disposition: attachment; filename="' . basename( str_replace( array( '"', "\r", "\n" ), '', $entry ) ) . '"' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $out = fopen( 'php://output', 'wb' ); $reader->copy_payload_to( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose fclose( $out ); $reader->close(); exit; } } $reader->close(); wp_die( esc_html__( 'That file was not found in the backup.', 'ai-siteark' ) ); } /** * SSRF guard: reject loopback, link-local, cloud-metadata and RFC1918 hosts * so the URL-restore fetcher can't be pointed at internal services. * * @param string $host Hostname or IP from the URL. * @return bool */ private static function is_internal_host( $host ) { $host = strtolower( trim( (string) $host, "[]" ) ); if ( '' === $host || 'localhost' === $host || false !== strpos( $host, '.localhost' ) || false !== strpos( $host, '.internal' ) || false !== strpos( $host, '.local' ) ) { return true; } $ips = self::resolve_ips( $host ); foreach ( $ips as $ip ) { if ( self::is_internal_ip( $ip ) ) { return true; } } // Fail closed: a host we could not resolve at all is not fetched. return empty( $ips ); } /** * Every IP a host resolves to — a literal IP, or all A and AAAA records. Collecting every * record (not just the first, and not IPv4-only) lets the SSRF guard reject a host that * mixes a public A with an internal AAAA, and closes IPv6 loopback/ULA/link-local targets * a gethostbyname()-only check would miss. Also reused to pin the fetch to a vetted IP. * * @param string $host Lower-cased hostname or literal IP. * @return string[] */ private static function resolve_ips( $host ) { if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return array( $host ); } $ips = array(); foreach ( array( DNS_A, DNS_AAAA ) as $type ) { $recs = @dns_get_record( $host, $type ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( is_array( $recs ) ) { foreach ( $recs as $r ) { if ( ! empty( $r['ip'] ) ) { $ips[] = $r['ip']; } if ( ! empty( $r['ipv6'] ) ) { $ips[] = $r['ipv6']; } } } } $v4 = gethostbyname( $host ); if ( $v4 && $v4 !== $host ) { $ips[] = $v4; } return array_values( array_unique( $ips ) ); } /** * Pin the next cURL fetch of $host to a vetted public IP via CURLOPT_RESOLVE, so cURL cannot * re-resolve the name to an internal address after the SSRF check (DNS-rebinding). Registers a * one-shot http_api_curl action and returns it so the caller can remove it after the fetch. * Returns null when there is nothing to pin (literal-IP URL, or no safe IP found). * * @param string $host Lower-cased hostname (brackets stripped). * @param string $scheme http|https. * @return callable|null The registered callback, or null. */ private static function pin_fetch_ip( $host, $scheme ) { if ( '' === $host || filter_var( $host, FILTER_VALIDATE_IP ) ) { return null; // Literal IP — cURL connects straight to it, no re-resolution. } // Prefer a safe IPv4 (simplest CURLOPT_RESOLVE form); fall back to a safe IPv6. $pick = ''; foreach ( self::resolve_ips( $host ) as $ip ) { if ( self::is_internal_ip( $ip ) ) { continue; } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $pick = $ip; break; } if ( '' === $pick ) { $pick = $ip; // First safe IPv6, kept only if no IPv4 turns up. } } if ( '' === $pick ) { return null; } $port = ( 'https' === $scheme ) ? 443 : 80; $addr = ( false !== strpos( $pick, ':' ) ) ? '[' . $pick . ']' : $pick; // Bracket IPv6. $cb = static function ( $handle ) use ( $host, $port, $addr ) { // CURLOPT_RESOLVE only affects requests to this exact host:port, so leaving it briefly // registered can't misdirect any other request. if ( function_exists( 'curl_setopt' ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt curl_setopt( $handle, CURLOPT_RESOLVE, array( $host . ':' . $port . ':' . $addr ) ); } }; add_action( 'http_api_curl', $cb ); return $cb; } /** * Whether an IPv4/IPv6 address is loopback, private, reserved, link-local or cloud-metadata. * * @param string $ip IP address. * @return bool */ private static function is_internal_ip( $ip ) { $ip = strtolower( (string) $ip ); if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return true; // Unparseable — treat as internal. } // IPv4-mapped IPv6 (::ffff:a.b.c.d): vet the embedded IPv4. if ( 0 === strpos( $ip, '::ffff:' ) && false !== strpos( $ip, '.' ) ) { $ip = substr( $ip, 7 ); } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return true; // private / reserved. } return ( '169.254.169.254' === $ip || 0 === strpos( $ip, '127.' ) ); } // IPv6. if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return true; } return ( '::1' === $ip || '::' === $ip || 0 === strpos( $ip, 'fe80:' ) || 0 === strpos( $ip, 'fc' ) || 0 === strpos( $ip, 'fd' ) ); } /** * SSRF-safe HTTP GET of a remote, user-or-token-supplied URL: reject internal hosts, * pin DNS to a vetted public IP (anti-rebinding), and never follow redirects. Reuses * the exact host-vetting that guards restore-from-URL, so callers (e.g. the Pro * site-to-site puller) don't re-implement it. $extra merges into wp_remote_get args * (headers, Range, timeout, stream/filename). * * @param string $url Absolute http(s) URL. * @param array $extra Extra wp_remote_get args. * @return array|\WP_Error The wp_remote_get response, or WP_Error (incl. a blocked URL). */ public static function guarded_get( $url, array $extra = array() ) { $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); $host = (string) wp_parse_url( $url, PHP_URL_HOST ); if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || self::is_internal_host( $host ) ) { return new \WP_Error( 'aisiteark_url_blocked', __( 'That URL is not allowed (it must be a public http(s) address).', 'ai-siteark' ) ); } $pin = self::pin_fetch_ip( strtolower( trim( (string) $host, '[]' ) ), $scheme ); // wp_remote_request honours an optional 'method' in $extra (defaults to GET), so the same // SSRF-guarded path serves both the ranged GET pulls and the POST backup-request. $args = array_merge( array( 'timeout' => 180, 'redirection' => 0, 'method' => 'GET' ), $extra ); $res = wp_remote_request( $url, $args ); if ( $pin ) { remove_action( 'http_api_curl', $pin ); } return $res; } /** * Save settings (scope, schedule, retention, password). * * @return void */ public function handle_save_settings() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_save_settings' ); $settings = Settings::get(); $section = isset( $_POST['aisiteark_section'] ) ? sanitize_key( wp_unslash( $_POST['aisiteark_section'] ) ) : 'general'; if ( 'general' === $section ) { $scope = array(); foreach ( array( 'database', 'uploads', 'themes', 'plugins', 'muplugins', 'other' ) as $k ) { $scope[ $k ] = ! empty( $_POST['scope'][ $k ] ); } $settings['scope'] = $scope; $settings['max_file_mb'] = isset( $_POST['max_file_mb'] ) ? max( 0, (int) $_POST['max_file_mb'] ) : 0; // G2: split archive into volumes of this many MB (0 = single file). Floor // at 10 MB when enabled so headers/entries aren't split into tiny parts. $part = isset( $_POST['max_part_mb'] ) ? max( 0, (int) $_POST['max_part_mb'] ) : 0; $settings['max_part_mb'] = ( $part > 0 && $part < 10 ) ? 10 : $part; // M5: custom exclude fragments (one per line). // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- split + sanitized below. $ex_raw = isset( $_POST['excludes'] ) ? (string) wp_unslash( $_POST['excludes'] ) : ''; $settings['excludes'] = array_values( array_filter( array_map( 'sanitize_text_field', array_map( 'trim', preg_split( '/[\r\n]+/', $ex_raw ) ) ) ) ); // P5: database slimming. Table names are stored verbatim but only ever matched // against the real table list in DB_Exporter::tables(), never interpolated. $settings['db_exclude_revisions'] = ! empty( $_POST['db_exclude_revisions'] ); $settings['db_exclude_transients'] = ! empty( $_POST['db_exclude_transients'] ); $settings['db_exclude_spam'] = ! empty( $_POST['db_exclude_spam'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- split + sanitized below. $tb_raw = isset( $_POST['db_exclude_tables'] ) ? (string) wp_unslash( $_POST['db_exclude_tables'] ) : ''; $settings['db_exclude_tables'] = array_values( array_filter( array_map( static function ( $t ) { // Only characters legal in a MySQL identifier survive. return preg_replace( '/[^A-Za-z0-9_$]/', '', trim( (string) $t ) ); }, preg_split( '/[\r\n,]+/', $tb_raw ) ) ) ); } Settings::save( $settings ); /** * Settings were saved. The Pro scheduler hooks this to reconcile the cron with the * new schedule. The free plugin has nothing to reconcile. * * @param string $section The settings section that was saved. */ do_action( 'aisiteark_settings_saved', $section ); /* translators: %s: settings section. */ self::log_activity( sprintf( __( 'Settings updated (%s)', 'ai-siteark' ), $section ) ); $redirect_tab = ( 'roles' === $section ) ? 'license' : $section; wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-settings', 'tab' => $redirect_tab, 'msg' => 'saved' ), admin_url( 'admin.php' ) ) ); exit; } /** * AJAX: run one restore step (starting from a stored archive on the first call). * * @return void */ public function ajax_restore_step() { check_ajax_referer( 'aisiteark_ajax', 'nonce' ); if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-siteark' ) ) ); } // Remember who we are BEFORE the restore rewrites wp_users / wp_usermeta. $aisiteark_uid = get_current_user_id(); if ( ! Restore_Runner::in_progress() ) { // Rate-limit only restore *starts* (never the per-step calls that follow). if ( ! Rate_Limit::allow( 'restore_start', 10, 5 * MINUTE_IN_SECONDS ) ) { wp_send_json_error( array( 'message' => Rate_Limit::message() ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. $is_rollback = ! empty( $_POST['rollback'] ); $file = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : ''; // J3 rollback: restore the DATABASE from a recovery point (no new snapshot, // no compat gate, DB component only) — a 1-click "undo" of the last restore. if ( $is_rollback ) { $path = Local_Store::recovery_path_for( $file ); if ( ! $path ) { wp_send_json_error( array( 'message' => __( 'Recovery point not found.', 'ai-siteark' ) ) ); } // Restore everything the recovery point contains (DB always; files too // when "also snapshot files" was on). No new snapshot, no compat gate. $started = Restore_Runner::start( $path, '', array(), true, array(), false ); if ( is_wp_error( $started ) ) { wp_send_json_error( array( 'message' => $started->get_error_message() ) ); } $res = Restore_Runner::step(); if ( self::keep_signed_in( $aisiteark_uid ) ) { $res['nonce'] = wp_create_nonce( 'aisiteark_ajax' ); } if ( ! empty( $res['error'] ) ) { wp_send_json_error( array( 'message' => $res['message'] ) ); } wp_send_json_success( $res ); } // Continuation steps carry no `file`, so an empty one here means a restore was // under way and lost its progress record — not that a backup file is missing. if ( '' === $file ) { wp_send_json_error( array( 'message' => __( 'The restore could not be continued — its progress record was lost. Reload this page; if a restore still shows as in progress, clear it and start again.', 'ai-siteark' ), ) ); } $path = Local_Store::path_for( $file ); if ( ! $path ) { wp_send_json_error( array( 'message' => __( 'Backup file not found.', 'ai-siteark' ) ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw password, used only to derive a key. $password = isset( $_POST['password'] ) ? (string) wp_unslash( $_POST['password'] ) : ''; // Selective restore (Phase F3): restore only the chosen components. $components = array(); if ( isset( $_POST['components'] ) && is_array( $_POST['components'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked above; each component sanitized with sanitize_key() below. foreach ( wp_unslash( $_POST['components'] ) as $aisiteark_c ) { $components[] = sanitize_key( $aisiteark_c ); } } // "Restore anyway" override for the pre-restore compatibility / integrity gate. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. $force = ! empty( $_POST['force'] ); // Opt-in "Replace all files": wipe the restored folders first so the site becomes // an exact copy of the backup instead of a merge. Off by default; destructive. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. $replace_all = ! empty( $_POST['replace_all'] ); // Optional find→replace pairs to apply to the database on restore. $replacements = array(); if ( isset( $_POST['replacements'] ) && is_array( $_POST['replacements'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked above; from/to sanitized with sanitize_textarea_field() below. foreach ( array_slice( wp_unslash( $_POST['replacements'] ), 0, 50 ) as $aisiteark_pair ) { if ( ! is_array( $aisiteark_pair ) ) { continue; } $from = isset( $aisiteark_pair['from'] ) ? substr( sanitize_textarea_field( $aisiteark_pair['from'] ), 0, 2000 ) : ''; $to = isset( $aisiteark_pair['to'] ) ? substr( sanitize_textarea_field( $aisiteark_pair['to'] ), 0, 2000 ) : ''; if ( '' !== $from ) { $replacements[] = array( 'from' => $from, 'to' => $to ); } } } $started = Restore_Runner::start( $path, $password, $components, $force, $replacements, null, $replace_all ); if ( is_wp_error( $started ) ) { // Flag the overridable pre-restore gate so the UI can offer "restore anyway". $data = ( 'aisiteark_restore_incompat' === $started->get_error_code() ) ? $started->get_error_data() : array(); wp_send_json_error( array( 'message' => $started->get_error_message(), 'code' => ! empty( $data['incompat'] ) ? 'incompat' : $started->get_error_code(), ) ); } } $res = Restore_Runner::step(); if ( self::keep_signed_in( $aisiteark_uid ) ) { // The old nonce was bound to the old session token; hand over a fresh one. $res['nonce'] = wp_create_nonce( 'aisiteark_ajax' ); } // P2: arm the watchdog AFTER the step — the step may have imported the source // site's `cron` option, wiping anything scheduled earlier in this request. If the // browser now goes away, cron (or a loopback) finishes the job. Restore_Runner::arm_watchdog(); if ( ! empty( $res['error'] ) ) { wp_send_json_error( array( 'message' => $res['message'] ) ); } wp_send_json_success( $res ); } /** * AJAX: report whether a restore is still running, and how the last one ended. * * The browser calls this when its restore step is interrupted (a timeout, a server * restart, or the brief logged-out window a migration can cause). A restore keeps * advancing on the server via the background watchdog even when the tab's own request * fails, so this lets the page wait for the real outcome and show success for a restore * that actually finished — instead of a misleading error. Auth returns on its own once * the restore completes (the site's URL, and therefore the login cookie, is restored), * so the browser simply retries this until it answers. * * @return void */ public function ajax_restore_status() { check_ajax_referer( 'aisiteark_ajax', 'nonce' ); if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-siteark' ) ) ); } $uid = get_current_user_id(); $res = array( 'in_progress' => Restore_Runner::in_progress(), 'last' => Restore_Runner::last_result(), ); // A restore may have replaced the users table; keep the operator signed in and hand // over a fresh nonce if we had to re-issue, exactly as the step endpoint does. if ( self::keep_signed_in( $uid ) ) { $res['nonce'] = wp_create_nonce( 'aisiteark_ajax' ); } wp_send_json_success( $res ); } /** * P2: drive an in-progress restore forward for a bounded slice of time. * * Shared by the cron watchdog, the cookie-less loopback and the manual "Resume" button. * Each call advances the restore for at most $budget seconds and then, if there is more * to do, re-arms the watchdog and fires the next loopback — so the job keeps moving with * no browser attached. * * @param float $budget Seconds to spend before handing off. * @return array{done:bool,error:bool,message:string,steps:int} */ private static function pump_restore( $budget = 20.0 ) { $steps = 0; $t0 = microtime( true ); ignore_user_abort( true ); do { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- lifts PHP's time limit for a long-running restore loop; harmless where disabled. @set_time_limit( 0 ); $res = Restore_Runner::step(); ++$steps; if ( ! empty( $res['error'] ) ) { return array( 'done' => false, 'error' => true, 'message' => (string) $res['message'], 'steps' => $steps ); } } while ( empty( $res['done'] ) && ( microtime( true ) - $t0 ) < $budget ); if ( ! empty( $res['done'] ) ) { return array( 'done' => true, 'error' => false, 'message' => '', 'steps' => $steps ); } // More to do and nobody is watching — keep it alive. Restore_Runner::arm_watchdog(); Restore_Runner::spawn_continue(); return array( 'done' => false, 'error' => false, 'message' => '', 'steps' => $steps ); } /** * P2: AJAX endpoint used by the loopback chain (and reachable to logged-out requests, * because a loopback carries no cookies). It NEVER starts a restore — it only advances * one that is already running, and only when the caller presents that restore's token. * * @return void */ public function ajax_restore_continue() { if ( ! Restore_Runner::in_progress() ) { wp_die( '', '', array( 'response' => 204 ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authenticated by the state-file token below. $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; if ( ! Restore_Runner::verify_token( $token ) ) { wp_die( '', '', array( 'response' => 403 ) ); } $res = self::pump_restore( 20.0 ); if ( $res['error'] ) { self::log_activity( sprintf( /* translators: %s: failure reason. */ __( 'Background restore failed: %s', 'ai-siteark' ), $res['message'] ) ); } wp_die( '', '', array( 'response' => 200 ) ); } /** * P2: cron watchdog. Fires ~60s after the last step if the restore is still running, * which is what rescues a restore whose tab was closed on a site with no traffic. * * @return void */ public function cron_restore_continue() { if ( ! Restore_Runner::in_progress() ) { return; } self::pump_restore( 20.0 ); } /** * P2: the manual escape hatch — for hosts where both WP-Cron and loopback requests are * disabled, the operator can push the restore along from the Restore page. * * @return void */ public function handle_resume_restore() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_resume_restore' ); $msg = 'restore_resumed'; if ( Restore_Runner::in_progress() ) { $res = self::pump_restore( 20.0 ); if ( $res['error'] ) { $msg = 'restore_failed'; } elseif ( $res['done'] ) { $msg = 'restore_done'; } } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark-restore', 'msg' => $msg ), admin_url( 'admin.php' ) ) ); exit; } /** * Keep the operator signed in across a restore. * * A restore replaces wp_users / wp_usermeta, so the auth cookie that authenticated * THIS request can stop validating the moment the database lands: the cookie is * signed against the user's password hash and a session token, and the backup may * predate that login (or hold a different password). The next step would then arrive * logged-out, admin-ajax would answer "0" (HTTP 400), and the restore would strand * one step short of finalize — leaving the site flagged as "restoring". * * While we still know the user id, re-issue a cookie signed against the RESTORED * user table. Only fires once, when the current cookie has actually stopped * validating; if that user no longer exists (a cross-site migration whose database * has different users) we leave it alone and the operator simply logs in again. * * @param int $uid User ID captured before the restore ran. * @return bool True when a new cookie was issued (the caller must then hand the * client a fresh nonce, since nonces are bound to the session token). */ private static function keep_signed_in( $uid ) { $uid = (int) $uid; if ( ! $uid || headers_sent() ) { return false; } // The restore just rewrote the users tables; drop any stale cached copy first. clean_user_cache( $uid ); // Still valid against the restored database? Then leave the cookie alone. if ( wp_validate_auth_cookie( '', 'logged_in' ) ) { return false; } if ( ! get_userdata( $uid ) ) { return false; // Not a user of the restored site — nothing we can safely re-issue. } // Make the freshly-issued cookie visible to THIS request, so the new session // token is the one used when we mint the replacement nonce. $capture = static function ( $logged_in_cookie ) { $_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie; }; add_action( 'set_logged_in_cookie', $capture ); wp_set_auth_cookie( $uid, false, is_ssl() ); remove_action( 'set_logged_in_cookie', $capture ); wp_set_current_user( $uid ); return true; } /** * AJAX: run one backup step (creating the job on the first call). * * @return void */ public function ajax_backup_step() { check_ajax_referer( 'aisiteark_ajax', 'nonce' ); if ( ! current_user_can( Capabilities::MANAGE ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-siteark' ) ) ); } $job = isset( $_POST['job'] ) ? (int) $_POST['job'] : 0; if ( ! $job ) { // Rate-limit only job *starts* (never the per-step calls that follow). if ( ! Rate_Limit::allow( 'backup_start', 10, 5 * MINUTE_IN_SECONDS ) ) { wp_send_json_error( array( 'message' => Rate_Limit::message() ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw password, used only to derive a key. $password = isset( $_POST['password'] ) ? (string) wp_unslash( $_POST['password'] ) : ''; // Optional export-time find→replace pairs (applied to the DB in the archive). $replacements = array(); if ( isset( $_POST['replacements'] ) && is_array( $_POST['replacements'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked above; from/to sanitized with sanitize_textarea_field() below. foreach ( array_slice( wp_unslash( $_POST['replacements'] ), 0, 50 ) as $aisiteark_pair ) { if ( ! is_array( $aisiteark_pair ) ) { continue; } $from = isset( $aisiteark_pair['from'] ) ? substr( sanitize_textarea_field( $aisiteark_pair['from'] ), 0, 2000 ) : ''; $to = isset( $aisiteark_pair['to'] ) ? substr( sanitize_textarea_field( $aisiteark_pair['to'] ), 0, 2000 ) : ''; if ( '' !== $from ) { $replacements[] = array( 'from' => $from, 'to' => $to ); } } } $id = Backup_Runner::start( $password, $replacements ); if ( is_wp_error( $id ) ) { wp_send_json_error( array( 'message' => $id->get_error_message() ) ); } $job = (int) $id; } $res = Backup_Runner::step( $job ); if ( ! empty( $res['error'] ) ) { wp_send_json_error( array( 'message' => $res['message'] ) ); } wp_send_json_success( $res ); } /** * Stream a stored archive as a download. * * @return void */ public function handle_download() { // A full-site backup contains every user's password hash + all secrets — // downloading it is admin-only, even though a MANAGE user can create one. if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_backup_download' ); if ( ! Rate_Limit::allow( 'download', 60, 10 * MINUTE_IN_SECONDS ) ) { wp_die( esc_html( Rate_Limit::message() ) ); } $file = isset( $_GET['file'] ) ? sanitize_file_name( wp_unslash( $_GET['file'] ) ) : ''; $path = Local_Store::path_for( $file ); if ( ! $path ) { wp_die( esc_html__( 'Backup file not found.', 'ai-siteark' ) ); } // G2: a specific volume of a split archive (vol=1 is the base file). // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. $vol = isset( $_GET['vol'] ) ? max( 1, (int) $_GET['vol'] ) : 1; if ( $vol > 1 ) { $path = $path . '.' . $vol; if ( ! is_file( $path ) ) { wp_die( esc_html__( 'Backup volume not found.', 'ai-siteark' ) ); } } nocache_headers(); header( 'Content-Type: application/octet-stream' ); header( 'Content-Disposition: attachment; filename=' . basename( $path ) ); header( 'Content-Length: ' . (int) filesize( $path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile readfile( $path ); exit; } /** * Download one family plugin's data blob. * * @return void */ public function handle_source_export() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_source_export' ); if ( ! Rate_Limit::allow( 'source_io', 30, 10 * MINUTE_IN_SECONDS ) ) { wp_die( esc_html( Rate_Limit::message() ) ); } $slug = isset( $_GET['slug'] ) ? sanitize_key( wp_unslash( $_GET['slug'] ) ) : ''; $src = Sources::get( $slug ); if ( ! $src ) { wp_die( esc_html__( 'Unknown data source.', 'ai-siteark' ) ); } $bytes = (string) call_user_func( $src['export'] ); nocache_headers(); header( 'Content-Type: application/octet-stream' ); header( 'Content-Disposition: attachment; filename=' . $slug . '-data-' . gmdate( 'Ymd-His' ) . '.aisvdata' ); header( 'Content-Length: ' . strlen( $bytes ) ); // Raw binary file download (Content-Type: application/octet-stream, sent as an // attachment). This is file content, not HTML — running it through an escaping // function would corrupt the downloaded file. Output verbatim by design. echo $bytes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary attachment; escaping would corrupt it. exit; } /** * Restore one family plugin's data blob from an upload. * * @return void */ public function handle_source_import() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_source_import' ); if ( ! Rate_Limit::allow( 'source_io', 30, 10 * MINUTE_IN_SECONDS ) ) { wp_die( esc_html( Rate_Limit::message() ) ); } $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( $_POST['slug'] ) ) : ''; $src = Sources::get( $slug ); $msg = 'source_failed'; if ( $src && $src['import'] && ! empty( $_FILES['blob']['tmp_name'] ) && is_uploaded_file( $_FILES['blob']['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput // phpcs:ignore WordPress.Security.ValidatedSanitizedInput, WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents $bytes = file_get_contents( $_FILES['blob']['tmp_name'] ); call_user_func( $src['import'], (string) $bytes ); $msg = 'source_restored'; } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark', 'msg' => $msg ), admin_url( 'admin.php' ) ) ); exit; } /** * Delete a stored archive (and its history row). * * @return void */ public function handle_delete() { if ( ! current_user_can( Capabilities::ADMIN ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'ai-siteark' ) ); } check_admin_referer( 'aisiteark_backup_delete' ); $file = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : ''; if ( $file ) { Local_Store::delete_file( $file ); global $wpdb; $wpdb->delete( $wpdb->prefix . 'aisiteark_backups', array( 'filename' => $file ), array( '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery /* translators: %s: backup filename. */ self::log_activity( sprintf( __( 'Backup deleted: %s', 'ai-siteark' ), $file ) ); } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-siteark', 'msg' => 'deleted' ), admin_url( 'admin.php' ) ) ); exit; } }