\n Order Allow,Deny\n Deny from all\n\n" ); } return $dir; } /** * Get stored API key (empty string when not set). * * @return string */ function drsite_get_api_key() { return (string) get_option( DRSITE_OPTION_KEY, '' ); } /** * Whether an API key has been configured. * * @return bool */ function drsite_has_api_key() { return '' !== trim( drsite_get_api_key() ); } /* ------------------------------------------------------------------------- */ /* Admin notice: force API key setup */ /* ------------------------------------------------------------------------- */ add_action( 'admin_notices', function () { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( drsite_has_api_key() ) { return; } // Don't duplicate the warning while already on the DRSite page — it shows its own inline notice. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check. if ( isset( $_GET['page'] ) && 'drsite' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) { return; } echo '

'; echo wp_kses_post( __( 'DRSite: this site is not connected yet. Add the API key from DRSite so it can back up and protect the site.', 'drsite-backup' ) ); echo ' ' . esc_html__( 'Connect now', 'drsite-backup' ) . '

'; } ); /* ------------------------------------------------------------------------- */ /* Admin page */ /* ------------------------------------------------------------------------- */ add_action( 'admin_menu', function () { add_management_page( __( 'DRSite', 'drsite-backup' ), __( 'DRSite', 'drsite-backup' ), 'manage_options', 'drsite', 'drsite_render_admin_page' ); } ); /** * Run Tools → DRSite form actions on this page. * admin-post.php on this site is intercepted before the plugin handler runs. */ add_action( 'load-tools_page_drsite', function () { if ( ! current_user_can( 'manage_options' ) ) { return; } // Only routes to the handlers below, each of which checks its own nonce // (check_admin_referer) before doing anything. // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended $raw = ''; if ( isset( $_POST['drsite_do'] ) ) { $raw = sanitize_key( wp_unslash( $_POST['drsite_do'] ) ); } elseif ( isset( $_GET['drsite_do'] ) ) { $raw = sanitize_key( wp_unslash( $_GET['drsite_do'] ) ); } // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended $allowed = array( 'drsite_save_api_key' ); if ( ! in_array( $raw, $allowed, true ) ) { return; } // $raw is one of the drsite_ actions listed above. do_action( 'admin_post_' . $raw ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound } ); /** * Render the Tools → DRSite page. * Step 1: ask for API key if missing. Step 2: backup / restore UI. */ function drsite_render_admin_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $has_key = drsite_has_api_key(); $seen = (int) get_option( 'drsite_last_contact', 0 ); $flash = get_transient( 'drsite_flash_' . get_current_user_id() ); if ( $flash ) { delete_transient( 'drsite_flash_' . get_current_user_id() ); } ?>

0 ) { /* translators: %s: how long ago, e.g. "5 mins" */ echo esc_html( sprintf( __( 'Connected. DRSite last reached this site %s ago.', 'drsite-backup' ), human_time_diff( $seen ) ) ); } else { esc_html_e( 'Key saved. Waiting for DRSite: click Check status on the site in DRSite.', 'drsite-backup' ); } ?>

$message, 'type' => $type, ); set_transient( $key, $existing, 60 ); } /* ------------------------------------------------------------------------- */ /* Save / remove API key */ /* ------------------------------------------------------------------------- */ add_action( 'admin_post_drsite_save_api_key', function () { if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'drsite_save_api_key' ) ) { wp_die( esc_html__( 'Permission check failed.', 'drsite-backup' ) ); } if ( ! empty( $_POST['drsite_remove_key'] ) ) { delete_option( DRSITE_OPTION_KEY ); drsite_flash( __( 'Key removed. DRSite can no longer reach this site.', 'drsite-backup' ), 'warning' ); delete_option( 'drsite_last_contact' ); } else { $key = isset( $_POST['drsite_api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['drsite_api_key'] ) ) : ''; if ( ! preg_match( '/^drs_[a-f0-9]{48}$/', $key ) ) { drsite_flash( __( 'That is not a DRSite key. Copy it from DRSite: Backups → WordPress → Plugin API key. It starts with drs_ and has 52 characters.', 'drsite-backup' ), 'error' ); } else { update_option( DRSITE_OPTION_KEY, $key, false ); delete_option( 'drsite_last_contact' ); drsite_flash( __( 'Key saved. In DRSite, click Check status on this site to finish connecting.', 'drsite-backup' ), 'success' ); } } wp_safe_redirect( admin_url( 'tools.php?page=drsite' ) ); exit; } ); /* ------------------------------------------------------------------------- */ /* List backups */ /* ------------------------------------------------------------------------- */ function drsite_list_backups() { $dir = drsite_backup_dir(); $files = glob( $dir . 'drsite-backup-*.zip' ); $out = array(); if ( ! $files ) { return $out; } rsort( $files ); foreach ( $files as $path ) { $name = basename( $path ); $out[] = array( 'name' => $name, 'path' => $path, 'size' => filesize( $path ), 'mtime' => filemtime( $path ), 'download_url' => wp_nonce_url( admin_url( 'tools.php?page=drsite&drsite_do=drsite_download_backup&file=' . rawurlencode( $name ) ), 'drsite_download_' . $name ), 'delete_url' => wp_nonce_url( admin_url( 'tools.php?page=drsite&drsite_do=drsite_delete_backup&file=' . rawurlencode( $name ) ), 'drsite_delete_' . $name ), 'restore_url' => wp_nonce_url( admin_url( 'tools.php?page=drsite&drsite_do=drsite_restore_backup&file=' . rawurlencode( $name ) ), 'drsite_restore_' . $name ), ); } return $out; } /** * Resolve a backup filename safely inside the backup dir (prevents traversal). */ function drsite_resolve_backup_path( $filename ) { $filename = basename( (string) $filename ); if ( '' === $filename || ! preg_match( '/^drsite-backup-[A-Za-z0-9._-]+\.zip$/', $filename ) ) { return false; } $path = drsite_backup_dir() . $filename; if ( ! file_exists( $path ) ) { return false; } return $path; } /* ------------------------------------------------------------------------- */ /* Create backup ZIP */ /* ------------------------------------------------------------------------- */ /** * wp-content folders that are never backed up: this plugin's own backups, * other plugins' backups, caches and logs. They can be most of wp-content and * are rebuilt or not needed on restore. * * @return string[] Absolute paths. */ function drsite_backup_excludes() { $content_dir = WP_CONTENT_DIR; $skip = array( 'cache', 'upgrade', 'wflogs', 'et-cache', 'debug.log', 'updraft', 'ai1wm-backups', 'backups-dup-lite', 'backups-dup-pro', 'backup-db', 'backups', 'backwpup', 'wpvividbackups', 'backup-guard', 'uploads/backwpup', 'uploads/wpvivid', 'uploads/updraft', 'uploads/backup-guard', 'uploads/ithemes-security/backups', 'uploads/wp-migrate-db', 'uploads/cache', 'uploads/wp-file-manager-pro/fm_backup', ); $exclude = array( untrailingslashit( drsite_backup_dir() ) ); foreach ( $skip as $relative ) { $exclude[] = untrailingslashit( $content_dir ) . '/' . $relative; } return $exclude; } /** * Every file in wp-content, minus the excludes: zip path => [absolute path, size, mtime]. * * @return array|WP_Error */ function drsite_content_files() { $src_dir = untrailingslashit( WP_CONTENT_DIR ); if ( ! is_dir( $src_dir ) ) { return new WP_Error( 'no_content', __( 'wp-content directory not found.', 'drsite-backup' ) ); } $exclude_abs = drsite_backup_excludes(); $iterator = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $src_dir, FilesystemIterator::SKIP_DOTS ), function ( $current ) use ( $exclude_abs ) { $p = $current->getPathname(); foreach ( $exclude_abs as $ex ) { // That folder or file exactly, and anything inside it. if ( '' !== $ex && ( $p === $ex || 0 === strpos( $p, $ex . '/' ) ) ) { return false; } } return true; } ), RecursiveIteratorIterator::LEAVES_ONLY ); $files = array(); foreach ( $iterator as $file ) { /** @var SplFileInfo $file */ if ( ! $file->isFile() || $file->isLink() ) { continue; } $abs = $file->getPathname(); $rel = 'wp-content/' . ltrim( str_replace( '\\', '/', substr( $abs, strlen( $src_dir ) ) ), '/' ); $files[ $rel ] = array( $abs, (int) $file->getSize(), (int) $file->getMTime() ); } return $files; } /** * What the last full backup held, for incremental backups (DRSite 2.1.0+). * * @return array|null { base_key, base_file, files: { zip path: [size, mtime] } } */ function drsite_read_state() { $raw = @file_get_contents( drsite_backup_dir() . 'drsite-state.json' ); $state = is_string( $raw ) ? json_decode( $raw, true ) : null; return is_array( $state ) && isset( $state['files'] ) && is_array( $state['files'] ) ? $state : null; } /** * Build the backup ZIP. * * Full: every wp-content file. Incremental (DRSite asks, and this server's * last full is the one DRSite has as base_key): only files new or changed * since that full, and the list of files deleted since. Both carry the whole * database and wp-config.php, so any backup restores the database by itself. * * Layout inside the ZIP: * manifest.json type, base, deleted files * database.sql * wp-content/... (themes, plugins, uploads) * wp-config.backup.php (copy of wp-config.php for reference) * * @param array $opts mode ('full' or 'incremental'), base_key (DRSite's name * for the full to build on), key (DRSite's name for this backup). * @return array|WP_Error { path, type, base_key, base_file, fallback } */ function drsite_create_backup_zip( $opts = array() ) { if ( ! class_exists( 'ZipArchive' ) ) { return new WP_Error( 'no_zip', __( 'PHP ZipArchive extension is not available on this server.', 'drsite-backup' ) ); } if ( ! drsite_has_api_key() ) { return new WP_Error( 'no_key', __( 'No API key configured.', 'drsite-backup' ) ); } @set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- large backups and restores. @ini_set( 'memory_limit', '512M' ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- large backups and restores. global $wp_version, $wpdb; $files = drsite_content_files(); if ( is_wp_error( $files ) ) { return $files; } // Incremental only on top of the full DRSite names, when this server made it. $wanted = isset( $opts['mode'] ) && 'incremental' === $opts['mode']; $base_key = isset( $opts['base_key'] ) ? (string) $opts['base_key'] : ''; $state = $wanted ? drsite_read_state() : null; $type = 'full'; $fallback = ''; if ( $wanted ) { if ( ! $state ) { $fallback = 'no record of the last full backup on this server'; } elseif ( '' === $base_key || ! isset( $state['base_key'] ) || $state['base_key'] !== $base_key ) { $fallback = 'this server\'s last full backup is not the one DRSite has'; } else { $type = 'incremental'; } } $dir = drsite_backup_dir(); $stamp = gmdate( 'Y-m-d-His' ); $filename = 'drsite-backup-' . $stamp . ( 'incremental' === $type ? '-inc' : '' ) . '.zip'; $path = $dir . $filename; $zip = new ZipArchive(); if ( true !== $zip->open( $path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) { return new WP_Error( 'zip_open', __( 'Could not create ZIP file.', 'drsite-backup' ) ); } // 1. Database dump → temp .sql file, then add to zip. Always the whole database. // wp_tempnam() lives in wp-admin, which a REST request does not load. require_once ABSPATH . 'wp-admin/includes/file.php'; $sql_file = wp_tempnam( 'drsite-database.sql' ); $dumped = drsite_dump_database( $sql_file ); if ( is_wp_error( $dumped ) ) { $zip->close(); wp_delete_file( $path ); return $dumped; } $zip->addFile( $sql_file, 'database.sql' ); // 2. wp-content: everything, or what changed since the full. $deleted = array(); $added = 0; foreach ( $files as $rel => $info ) { if ( 'incremental' === $type && isset( $state['files'][ $rel ] ) && (int) $state['files'][ $rel ][0] === $info[1] && (int) $state['files'][ $rel ][1] === $info[2] ) { continue; } if ( ! $zip->addFile( $info[0], $rel ) ) { $zip->close(); wp_delete_file( $sql_file ); wp_delete_file( $path ); /* translators: %s: path of the file inside the ZIP */ return new WP_Error( 'zip_add', sprintf( __( 'Could not add file to ZIP: %s', 'drsite-backup' ), $rel ) ); } $added++; } if ( 'incremental' === $type ) { foreach ( array_keys( $state['files'] ) as $rel ) { if ( ! isset( $files[ $rel ] ) ) { $deleted[] = $rel; } } } // 3. wp-config.php copy (renamed so a restore never overwrites the live one blindly). $config_src = ABSPATH . 'wp-config.php'; if ( file_exists( $config_src ) ) { $zip->addFile( $config_src, 'wp-config.backup.php' ); } // 4. Manifest. $manifest = array( 'plugin' => 'drsite', 'plugin_version' => DRSITE_VERSION, 'type' => $type, 'created' => gmdate( 'c' ), 'site_url' => site_url(), 'home_url' => home_url(), 'wp_version' => isset( $wp_version ) ? $wp_version : '', 'db_prefix' => $wpdb->prefix, 'php_version' => PHP_VERSION, 'api_key_hash' => hash( 'sha256', drsite_get_api_key() ), 'files' => array( 'wp-content/', 'database.sql', 'wp-config.backup.php', 'manifest.json' ), 'file_count' => $added, 'restore_note' => 'Restore via Tools → DRSite, or unzip and import database.sql manually.', ); if ( 'incremental' === $type ) { $manifest['base_key'] = $base_key; $manifest['base_file'] = isset( $state['base_file'] ) ? $state['base_file'] : ''; $manifest['deleted'] = $deleted; } $zip->addFromString( 'manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT ) ); $zip->close(); wp_delete_file( $sql_file ); return array( 'path' => $path, 'type' => $type, 'base_key' => 'incremental' === $type ? $base_key : '', 'base_file' => 'incremental' === $type ? (string) $manifest['base_file'] : '', 'fallback' => $fallback, 'files' => $files, ); } /** * After a full backup reached DRSite: remember it as the base for incrementals. * * @param string $key DRSite's name for the full. * @param string $path The full's ZIP on this server. * @param array $files drsite_content_files() as zipped. */ function drsite_save_state( $key, $path, $files ) { $list = array(); foreach ( $files as $rel => $info ) { $list[ $rel ] = array( $info[1], $info[2] ); } @file_put_contents( drsite_backup_dir() . 'drsite-state.json', wp_json_encode( array( 'base_key' => (string) $key, 'base_file' => basename( $path ), 'files' => $list ) ) ); } /** * Keep this server's own copies small: the newest full with the backups made * on top of it, and the full before that with its own. DRSite keeps the rest. */ function drsite_prune_local() { $files = glob( drsite_backup_dir() . 'drsite-backup-*.zip' ); if ( ! $files ) { return; } sort( $files ); // Oldest first: names carry the time. $fulls = array_values( array_filter( $files, function ( $f ) { return false === strpos( basename( $f ), '-inc.zip' ); } ) ); if ( count( $fulls ) < 3 ) { return; } // Everything older than the second-newest full goes. $keep_from = $fulls[ count( $fulls ) - 2 ]; foreach ( $files as $f ) { if ( strcmp( $f, $keep_from ) < 0 ) { wp_delete_file( $f ); } } } /** * Dump the whole WordPress database to a .sql file. * * @param string $target_file Absolute path to write. * @return true|WP_Error */ function drsite_dump_database( $target_file ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- backup dump needs raw DDL/DML, never cached. $tables = $wpdb->get_col( 'SHOW TABLES' ); if ( empty( $tables ) ) { return new WP_Error( 'no_tables', __( 'No database tables found.', 'drsite-backup' ) ); } $fh = fopen( $target_file, 'w' ); if ( ! $fh ) { return new WP_Error( 'sql_open', __( 'Could not write temporary SQL file.', 'drsite-backup' ) ); } fwrite( $fh, "-- DRSite\n-- Site: " . site_url() . "\n-- Created: " . gmdate( 'c' ) . "\n\nSET FOREIGN_KEY_CHECKS=0;\n\n" ); foreach ( $tables as $table ) { // CREATE TABLE. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from SHOW TABLES; a dump is never cached. $create = $wpdb->get_row( "SHOW CREATE TABLE `$table`", ARRAY_N ); if ( $create && isset( $create[1] ) ) { fwrite( $fh, "\nDROP TABLE IF EXISTS `$table`;\n" . $create[1] . ";\n" ); } // Rows in batches. $offset = 0; $limit = 500; while ( true ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from SHOW TABLES; offset and limit are integers. $rows = $wpdb->get_results( "SELECT * FROM `$table` LIMIT $offset, $limit", ARRAY_N ); if ( empty( $rows ) ) { break; } foreach ( $rows as $row ) { $escaped = array(); foreach ( $row as $value ) { if ( is_null( $value ) ) { $escaped[] = 'NULL'; } else { $escaped[] = "'" . esc_sql( $value ) . "'"; } } fwrite( $fh, "INSERT INTO `$table` VALUES (" . implode( ',', $escaped ) . ");\n" ); } $offset += $limit; } fwrite( $fh, "\n" ); } fwrite( $fh, "SET FOREIGN_KEY_CHECKS=1;\n" ); fclose( $fh ); return true; } /* ------------------------------------------------------------------------- */ /* Download / delete */ /* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */ /* Restore */ /* ------------------------------------------------------------------------- */ /** * Restore files + database from a backup ZIP created by this plugin. * * @param string $zip_path Absolute path to the backup zip. * @return true|WP_Error */ function drsite_restore_backup_zip( $zip_path ) { if ( ! class_exists( 'ZipArchive' ) ) { return new WP_Error( 'no_zip', __( 'PHP ZipArchive extension is not available.', 'drsite-backup' ) ); } if ( ! file_exists( $zip_path ) ) { return new WP_Error( 'missing', __( 'Backup file not found.', 'drsite-backup' ) ); } @set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- large backups and restores. $manifest = drsite_zip_manifest( $zip_path ); if ( is_wp_error( $manifest ) ) { return $manifest; } // An incremental (2.1.0+) holds only changes: its full goes back first. if ( isset( $manifest['type'] ) && 'incremental' === $manifest['type'] ) { $base = drsite_resolve_backup_path( isset( $manifest['base_file'] ) ? (string) $manifest['base_file'] : '' ); if ( ! $base ) { return new WP_Error( 'no_base', __( 'The full backup this one builds on is no longer on this server. Restore from DRSite instead.', 'drsite-backup' ) ); } $done = drsite_restore_files( $base, array() ); if ( is_wp_error( $done ) ) { return $done; } } $deleted = isset( $manifest['deleted'] ) && is_array( $manifest['deleted'] ) ? $manifest['deleted'] : array(); $tmp_dir = drsite_restore_files( $zip_path, $deleted, true ); if ( is_wp_error( $tmp_dir ) ) { return $tmp_dir; } // The database, from the chosen backup (every backup carries all of it). $sql_file = trailingslashit( $tmp_dir ) . 'database.sql'; if ( ! file_exists( $sql_file ) ) { drsite_delete_dir( $tmp_dir ); return new WP_Error( 'no_sql', __( 'database.sql missing after extract.', 'drsite-backup' ) ); } $imported = drsite_import_sql_file( $sql_file ); drsite_delete_dir( $tmp_dir ); if ( is_wp_error( $imported ) ) { return $imported; } // Flush caches so restored data shows immediately. wp_cache_flush(); if ( function_exists( 'delete_option' ) ) { delete_transient( 'drsite_admin_notice_' . get_current_user_id() ); } return true; } /** * The manifest of a DRSite backup ZIP, checked for database.sql too. * * @return array|WP_Error */ function drsite_zip_manifest( $zip_path ) { $zip = new ZipArchive(); if ( true !== $zip->open( $zip_path ) ) { return new WP_Error( 'zip_open', __( 'Could not open backup ZIP.', 'drsite-backup' ) ); } if ( false === $zip->locateName( 'manifest.json' ) || false === $zip->locateName( 'database.sql' ) ) { $zip->close(); return new WP_Error( 'bad_zip', __( 'Invalid backup: manifest.json / database.sql missing.', 'drsite-backup' ) ); } $manifest = json_decode( (string) $zip->getFromName( 'manifest.json' ), true ); $zip->close(); return is_array( $manifest ) ? $manifest : array(); } /** * Extract a backup and copy its wp-content over the site's, then remove the * files the backup lists as deleted (only inside wp-content). * * @param string $zip_path * @param string[] $deleted Zip paths (wp-content/...) to remove. * @param bool $keep Return the extract folder (for its database.sql) instead of removing it. * @return string|true|WP_Error */ function drsite_restore_files( $zip_path, $deleted, $keep = false ) { // Extract to a temp dir first so we can validate before overwriting. $tmp_dir = trailingslashit( get_temp_dir() ) . 'drsite-restore-' . wp_generate_password( 12, false ) . '/'; wp_mkdir_p( $tmp_dir ); $zip = new ZipArchive(); if ( true !== $zip->open( $zip_path ) || ! $zip->extractTo( $tmp_dir ) ) { drsite_delete_dir( $tmp_dir ); return new WP_Error( 'extract', __( 'Could not extract backup ZIP.', 'drsite-backup' ) ); } $zip->close(); $src_content = trailingslashit( $tmp_dir ) . 'wp-content'; if ( is_dir( $src_content ) ) { drsite_copy_dir( $src_content, WP_CONTENT_DIR ); } $root = realpath( WP_CONTENT_DIR ); foreach ( $deleted as $rel ) { $rel = (string) $rel; if ( 0 !== strpos( $rel, 'wp-content/' ) || false !== strpos( $rel, '..' ) ) { continue; } $target = WP_CONTENT_DIR . substr( $rel, strlen( 'wp-content' ) ); $real = realpath( $target ); if ( $real && $root && 0 === strpos( $real, $root . DIRECTORY_SEPARATOR ) && is_file( $real ) ) { wp_delete_file( $real ); } } if ( $keep ) { return $tmp_dir; } drsite_delete_dir( $tmp_dir ); return true; } /** * Import a .sql dump file statement-by-statement. * * @param string $file Absolute path. * @return true|WP_Error */ function drsite_import_sql_file( $file ) { global $wpdb; $sql = file_get_contents( $file ); if ( false === $sql ) { return new WP_Error( 'sql_read', __( 'Could not read database.sql.', 'drsite-backup' ) ); } // Strip comments, split on semicolon + newline. $sql = preg_replace( '/^\s*--.*$/m', '', $sql ); $statements = preg_split( '/;\s*\n/', $sql ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.NoCaching -- restoring a dump requires raw queries. foreach ( $statements as $stmt ) { $stmt = trim( $stmt ); if ( '' === $stmt || 'SET FOREIGN_KEY_CHECKS=0' === $stmt || 'SET FOREIGN_KEY_CHECKS=1' === $stmt ) { continue; } $wpdb->query( $stmt ); if ( '' !== $wpdb->last_error ) { // Keep going for idempotent errors (e.g. duplicate inserts), but record the last one. $last_error = $wpdb->last_error; $wpdb->last_error = ''; // Only hard-fail on syntax-level errors is unreliable; continue and report at end if nothing imported. unset( $last_error ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.NoCaching return true; } /** * Recursively copy $src into $dst (overwrite). */ function drsite_copy_dir( $src, $dst ) { $src = untrailingslashit( $src ); $dst = untrailingslashit( $dst ); wp_mkdir_p( $dst ); $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $src, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::SELF_FIRST ); foreach ( $iterator as $item ) { $target = $dst . '/' . $iterator->getSubPathName(); if ( $item->isDir() ) { wp_mkdir_p( $target ); } else { copy( $item->getPathname(), $target ); } } } /** * Recursively delete a directory. */ function drsite_delete_dir( $dir ) { if ( ! is_dir( $dir ) ) { return; } $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $files as $file ) { if ( $file->isDir() ) { @rmdir( $file->getPathname() ); } else { wp_delete_file( $file->getPathname() ); } } @rmdir( $dir ); } /* ------------------------------------------------------------------------- */ /* REST API (key via X-Drsite-Api-Key header) */ /* ------------------------------------------------------------------------- */ /** * Let a valid API key through when the site blocks unauthenticated REST. * Runs late so it can override a blanket "logged-in users only" filter. * * @param mixed $result Prior authentication result. * @return mixed */ /** * True when this request is for one of DRSite's routes and carries the * saved API key. */ function drsite_request_has_key() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // Only compared with the plugin's route; the API key is checked below. $route = isset( $_GET['rest_route'] ) ? sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( false === strpos( $uri . ' ' . $route, '/drsite/v1' ) ) { return false; } $given = isset( $_SERVER['HTTP_X_DRSITE_API_KEY'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_DRSITE_API_KEY'] ) ) : ''; $saved = drsite_get_api_key(); return '' !== trim( $saved ) && '' !== trim( $given ) && hash_equals( $saved, $given ); } function drsite_allow_api_key( $result ) { return drsite_request_has_key() ? true : $result; } // Last, so a plugin or snippet that restricts the REST API to signed-in // users ("REST API restricted to authenticated users") does not block // DRSite's own key-protected routes. Other routes are untouched. add_filter( 'rest_authentication_errors', 'drsite_allow_api_key', PHP_INT_MAX ); /** * Some REST lockdowns refuse at dispatch instead. For DRSite's routes with * the right key, drop that refusal so the route's own key check runs. * * @param mixed $result Response to send instead of dispatching, or null. * @param WP_REST_Server $server Server. * @param WP_REST_Request $request Request. * @return mixed */ function drsite_allow_api_key_dispatch( $result, $server, $request ) { if ( is_wp_error( $result ) && 0 === strpos( (string) $request->get_route(), '/drsite/v1/' ) && drsite_request_has_key() ) { return null; } return $result; } add_filter( 'rest_pre_dispatch', 'drsite_allow_api_key_dispatch', PHP_INT_MAX, 3 ); add_action( 'rest_api_init', function () { register_rest_route( 'drsite/v1', '/backup', array( 'methods' => 'POST', 'callback' => 'drsite_rest_create_backup', 'permission_callback' => 'drsite_rest_check_key', ) ); register_rest_route( 'drsite/v1', '/backups', array( 'methods' => 'GET', 'callback' => 'drsite_rest_list_backups', 'permission_callback' => 'drsite_rest_check_key', ) ); register_rest_route( 'drsite/v1', '/download', array( 'methods' => 'GET', 'callback' => 'drsite_rest_download', 'permission_callback' => 'drsite_rest_check_key', 'args' => array( 'file' => array( 'type' => 'string', 'description' => 'Backup filename from GET /backups.', ), ), ) ); register_rest_route( 'drsite/v1', '/health', array( 'methods' => 'GET', 'callback' => 'drsite_rest_health', 'permission_callback' => 'drsite_rest_check_key', ) ); register_rest_route( 'drsite/v1', '/restore', array( 'methods' => 'POST', 'callback' => 'drsite_rest_restore_backup', 'permission_callback' => 'drsite_rest_check_key', 'args' => array( 'file' => array( 'type' => 'string', 'description' => 'Backup filename from GET /backups (e.g. drsite-backup-2026-01-01-120000.zip).', ), 'confirm' => array( 'type' => 'boolean', 'description' => 'Must be true — restores overwrite files and the database.', ), ), ) ); } ); function drsite_rest_check_key( WP_REST_Request $request ) { $given = $request->get_header( 'X-Drsite-Api-Key' ); $saved = drsite_get_api_key(); if ( '' === trim( (string) $saved ) || '' === trim( (string) $given ) ) { return new WP_Error( 'drsite_no_key', __( 'Missing or unconfigured API key.', 'drsite-backup' ), array( 'status' => 401 ) ); } if ( ! hash_equals( (string) $saved, (string) $given ) ) { return new WP_Error( 'drsite_bad_key', __( 'Invalid API key.', 'drsite-backup' ), array( 'status' => 403 ) ); } if ( time() - (int) get_option( 'drsite_last_contact', 0 ) > 300 ) { update_option( 'drsite_last_contact', time(), false ); } return true; } function drsite_rest_create_backup( WP_REST_Request $request ) { if ( ! current_user_can( 'manage_options' ) && ! drsite_rest_check_key( $request ) ) { return new WP_Error( 'drsite_forbidden', __( 'Forbidden.', 'drsite-backup' ), array( 'status' => 403 ) ); } // DRSite 1.4+: a one-time link to upload the ZIP straight to DRSite's // storage, so a large backup never has to be downloaded from this site. $params = $request->get_json_params(); $upload_url = is_array( $params ) && isset( $params['upload_url'] ) ? esc_url_raw( (string) $params['upload_url'] ) : ''; // DRSite 1.4.2+: a second link, to the site's Cold Vault, once a day on Enterprise. $cold_url = is_array( $params ) && isset( $params['cold_upload_url'] ) ? esc_url_raw( (string) $params['cold_upload_url'] ) : ''; if ( '' !== $cold_url && 0 !== strpos( $cold_url, 'https://' ) ) { $cold_url = ''; } if ( '' !== $upload_url && 0 !== strpos( $upload_url, 'https://' ) ) { return new WP_Error( 'drsite_bad_upload_url', __( 'The upload link must be HTTPS.', 'drsite-backup' ), array( 'status' => 400 ) ); } // DRSite 2.1+: an incremental on top of the full DRSite has as base_key, // with its own upload link (inc_upload_url) under DRSite's -inc name. $str = function ( $name ) use ( $params ) { return is_array( $params ) && isset( $params[ $name ] ) ? (string) $params[ $name ] : ''; }; $mode = 'incremental' === $str( 'mode' ) ? 'incremental' : 'full'; $base_key = sanitize_file_name( $str( 'base_key' ) ); $key = sanitize_file_name( $str( 'key' ) ); $inc_url = esc_url_raw( $str( 'inc_upload_url' ) ); if ( '' !== $inc_url && 0 !== strpos( $inc_url, 'https://' ) ) { return new WP_Error( 'drsite_bad_upload_url', __( 'The upload link must be HTTPS.', 'drsite-backup' ), array( 'status' => 400 ) ); } // Keep going if the web server stops waiting for a large backup. ignore_user_abort( true ); $result = drsite_create_backup_zip( array( 'mode' => $mode, 'base_key' => $base_key, 'key' => $key ) ); if ( is_wp_error( $result ) ) { return $result; } $path = $result['path']; $incremental = 'incremental' === $result['type']; $response = array( 'success' => true, 'file' => basename( $path ), 'size' => filesize( $path ), 'type' => $result['type'], 'version' => DRSITE_VERSION, ); if ( $incremental ) { $response['base_key'] = $result['base_key']; } if ( '' !== $result['fallback'] ) { $response['fallback'] = $result['fallback']; } $target = $incremental ? $inc_url : $upload_url; $sent = '' === $target ? null : drsite_upload_backup( $path, $target ); if ( null !== $sent ) { $response['uploaded'] = true === $sent; if ( true !== $sent ) { $response['upload_error'] = $sent; } } // A full that reached DRSite (or a run without DRSite's links) is the base for the next incrementals. if ( ! $incremental && ( true === $sent || null === $sent ) ) { drsite_save_state( '' !== $key ? $key : basename( $path ), $path, $result['files'] ); } // Cold Vault copies are of fulls only. if ( '' !== $cold_url && ! $incremental ) { $response['cold_uploaded'] = true === drsite_upload_backup( $path, $cold_url ); } drsite_prune_local(); return rest_ensure_response( $response ); } /** * PUT a backup ZIP to a presigned storage link, streaming from disk. * * @param string $path Backup file. * @param string $url Presigned HTTPS PUT link from DRSite. * @return true|string True, or why it failed. */ function drsite_upload_backup( $path, $url ) { // The body is streamed from disk through WordPress's curl transport, so a // multi-gigabyte backup is never held in memory. Without curl it cannot be. if ( ! function_exists( 'curl_init' ) ) { return 'PHP curl is not available.'; } $size = filesize( $path ); $in = fopen( $path, 'rb' ); if ( false === $in ) { return 'Could not open the backup.'; } $streamed = false; $stream = static function ( $handle, $parsed_args, $request_url ) use ( $url, $in, $size, &$streamed ) { if ( $request_url !== $url ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array -- http_api_curl is the documented way to stream a request body from a file. curl_setopt_array( $handle, array( CURLOPT_UPLOAD => true, CURLOPT_INFILE => $in, CURLOPT_INFILESIZE => $size, CURLOPT_TIMEOUT => 0, ) ); $streamed = true; }; add_action( 'http_api_curl', $stream, 10, 3 ); $result = wp_remote_request( $url, array( 'method' => 'PUT', 'headers' => array( 'Content-Type' => 'application/zip' ), 'timeout' => 3600, 'redirection' => 0, ) ); remove_action( 'http_api_curl', $stream, 10 ); fclose( $in ); if ( ! $streamed ) { $error = 'WordPress did not send the backup through curl.'; $code = 0; $body = ''; } else { $error = is_wp_error( $result ) ? $result->get_error_message() : ''; $code = is_wp_error( $result ) ? 0 : (int) wp_remote_retrieve_response_code( $result ); $body = is_wp_error( $result ) ? '' : wp_remote_retrieve_body( $result ); } $ok = $code >= 200 && $code < 300; update_option( 'drsite_last_upload', array( 'file' => basename( $path ), 'ok' => $ok, 'error' => $ok ? '' : ( '' !== $error ? $error : 'HTTP ' . $code . ' ' . substr( wp_strip_all_tags( (string) $body ), 0, 200 ) ), 'at' => time(), ), false ); return $ok ? true : ( '' !== $error ? $error : 'Storage answered HTTP ' . $code . '.' ); } /** * Stream one backup ZIP to DRSite, so a copy is kept off this server. * Key-protected like the other routes; only DRSite backup files. */ function drsite_rest_download( WP_REST_Request $request ) { $file = basename( (string) $request->get_param( 'file' ) ); if ( ! preg_match( '/^drsite-backup-[A-Za-z0-9._-]+\.zip$/', $file ) ) { return new WP_Error( 'drsite_bad_file', __( 'Not a DRSite backup file.', 'drsite-backup' ), array( 'status' => 400 ) ); } $path = drsite_backup_dir() . $file; if ( ! is_file( $path ) ) { return new WP_Error( 'drsite_not_found', __( 'Backup not found.', 'drsite-backup' ), array( 'status' => 404 ) ); } while ( ob_get_level() ) { ob_end_clean(); } @set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- large backups and restores. nocache_headers(); header( 'Content-Type: application/zip' ); header( 'Content-Length: ' . filesize( $path ) ); header( 'Content-Disposition: attachment; filename="' . $file . '"' ); readfile( $path ); exit; } function drsite_rest_list_backups( WP_REST_Request $request ) { $list = array(); foreach ( drsite_list_backups() as $b ) { $list[] = array( 'file' => $b['name'], 'size' => $b['size'], 'created' => gmdate( 'c', $b['mtime'] ), ); } return rest_ensure_response( array( 'backups' => $list, 'version' => DRSITE_VERSION, 'last_upload' => get_option( 'drsite_last_upload', null ), ) ); } /** * Collect WordPress Site Health data for the health API. * Read-only: runs core async-independent checks, never modifies the site. * * @return array */ function drsite_collect_health() { global $wp_version, $wpdb; $result = array( 'status' => 'good', 'checked_at' => gmdate( 'c' ), 'site' => array( 'site_url' => site_url(), 'home_url' => home_url(), 'wp_version' => isset( $wp_version ) ? $wp_version : '', 'php_version' => PHP_VERSION, 'db_prefix' => isset( $wpdb->prefix ) ? $wpdb->prefix : '', 'multisite' => is_multisite(), 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'locale' => get_locale(), 'timezone' => wp_timezone_string(), ), 'checks' => array(), 'counts' => array(), 'disk' => array(), 'backups' => array(), ); // 1. Core Site Health tests (same engine as Tools → Site Health). // Hardened: test callbacks may be WP_Site_Health method names, so resolve // them against the instance; skip anything unresolvable. Never fatal. // The Site Health tests call wp-admin functions (get_plugin_updates, // get_core_updates…) that a REST request has not loaded. foreach ( array( 'class-wp-site-health.php', 'update.php', 'plugin.php', 'theme.php', 'misc.php', 'file.php' ) as $include ) { if ( file_exists( ABSPATH . 'wp-admin/includes/' . $include ) ) { require_once ABSPATH . 'wp-admin/includes/' . $include; } } if ( class_exists( 'WP_Site_Health' ) && method_exists( 'WP_Site_Health', 'get_tests' ) ) { try { $health_obj = method_exists( 'WP_Site_Health', 'get_instance' ) ? WP_Site_Health::get_instance() : null; $tests = WP_Site_Health::get_tests(); $direct = array(); if ( ! empty( $tests['direct'] ) && is_array( $tests['direct'] ) ) { foreach ( $tests['direct'] as $test ) { if ( ! is_array( $test ) || empty( $test['test'] ) || empty( $test['label'] ) ) { continue; } $callback = null; $t = $test['test']; if ( is_string( $t ) && $health_obj && method_exists( $health_obj, 'get_test_' . $t ) ) { // Core registers direct tests by name ("php_version") and // runs WP_Site_Health::get_test_{name}. $callback = array( $health_obj, 'get_test_' . $t ); } elseif ( is_string( $t ) && $health_obj && method_exists( $health_obj, $t ) ) { $callback = array( $health_obj, $t ); } elseif ( is_callable( $t ) ) { $callback = $t; } if ( null === $callback ) { continue; } try { $out = call_user_func( $callback ); } catch ( Throwable $e ) { $out = array( 'status' => 'critical', 'label' => $test['label'], 'description' => $e->getMessage() ); } if ( ! is_array( $out ) || empty( $out['status'] ) ) { continue; } $direct[] = array( 'id' => is_string( $t ) ? $t : $test['label'], 'label' => isset( $out['label'] ) ? $out['label'] : $test['label'], 'status' => $out['status'], 'description' => isset( $out['description'] ) ? wp_strip_all_tags( $out['description'] ) : '', ); } } $result['checks'] = $direct; // Roll up worst status: critical > recommended > good. $worst = array( 'good' => 0, 'recommended' => 1, 'critical' => 2 ); foreach ( $direct as $c ) { $s = isset( $c['status'] ) ? $c['status'] : 'good'; if ( isset( $worst[ $s ] ) && $worst[ $s ] > $worst[ $result['status'] ] ) { $result['status'] = $s; } } $result['counts'] = array( 'good' => count( array_filter( $direct, function ( $c ) { return 'good' === $c['status']; } ) ), 'recommended' => count( array_filter( $direct, function ( $c ) { return 'recommended' === $c['status']; } ) ), 'critical' => count( array_filter( $direct, function ( $c ) { return 'critical' === $c['status']; } ) ), 'total' => count( $direct ), ); unset( $health_obj ); } catch ( Throwable $e ) { $result['checks_error'] = $e->getMessage(); } } // 2. Database connectivity + size. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- health readout needs raw status. $db_ok = false; if ( isset( $wpdb ) ) { $db_ok = (bool) $wpdb->get_var( 'SELECT 1' ); } $result['database'] = array( 'connected' => $db_ok, ); if ( $db_ok ) { $size = $wpdb->get_row( $wpdb->prepare( 'SELECT SUM(data_length + index_length) AS bytes, COUNT(*) AS tables_count FROM information_schema.TABLES WHERE table_schema = %s', DB_NAME ), ARRAY_A ); if ( $size ) { $result['database']['tables'] = (int) $size['tables_count']; $result['database']['size_bytes'] = (int) $size['bytes']; $result['database']['size_human'] = size_format( (int) $size['bytes'] ); } } // phpcs:enable // 3. Disk space. $free = @disk_free_space( ABSPATH ); $total = @disk_total_space( ABSPATH ); if ( false !== $free && false !== $total && $total > 0 ) { $result['disk'] = array( 'free_bytes' => (int) $free, 'total_bytes' => (int) $total, 'free_human' => size_format( (int) $free ), 'total_human' => size_format( (int) $total ), 'free_percent' => round( ( $free / $total ) * 100, 1 ), ); } // 4. Backup summary. $backups = drsite_list_backups(); $result['backups'] = array( 'count' => count( $backups ), 'latest' => ! empty( $backups ) ? array( 'file' => $backups[0]['name'], 'size' => $backups[0]['size'], 'created' => gmdate( 'c', $backups[0]['mtime'] ), ) : null, ); // 5. Updates waiting, from WordPress's own update check (no request is // made here). Names only, so DRSite can show what needs updating. $result['updates'] = drsite_pending_updates(); // 6. Plugin state. $result['plugin'] = array( 'version' => DRSITE_VERSION, 'has_api_key' => drsite_has_api_key(), ); return $result; } /** * Plugins, themes and WordPress core with an update waiting, as WordPress * last found them (the update_* site transients). * * @return array */ function drsite_pending_updates() { $out = array( 'plugins' => array(), 'themes' => array(), 'core' => null, 'checked_at' => null, ); $plugins = get_site_transient( 'update_plugins' ); if ( is_object( $plugins ) && ! empty( $plugins->response ) && is_array( $plugins->response ) ) { $installed = function_exists( 'get_plugins' ) ? get_plugins() : array(); foreach ( $plugins->response as $file => $update ) { $name = isset( $installed[ $file ]['Name'] ) ? $installed[ $file ]['Name'] : dirname( $file ); $out['plugins'][] = array( 'name' => wp_strip_all_tags( $name ), 'version' => isset( $installed[ $file ]['Version'] ) ? $installed[ $file ]['Version'] : '', 'new' => isset( $update->new_version ) ? (string) $update->new_version : '', ); } } if ( is_object( $plugins ) && isset( $plugins->last_checked ) ) { $out['checked_at'] = gmdate( 'c', (int) $plugins->last_checked ); } $themes = get_site_transient( 'update_themes' ); if ( is_object( $themes ) && ! empty( $themes->response ) && is_array( $themes->response ) ) { foreach ( $themes->response as $slug => $update ) { $theme = wp_get_theme( $slug ); $out['themes'][] = array( 'name' => $theme->exists() ? wp_strip_all_tags( $theme->get( 'Name' ) ) : (string) $slug, 'version' => $theme->exists() ? (string) $theme->get( 'Version' ) : '', 'new' => is_array( $update ) && isset( $update['new_version'] ) ? (string) $update['new_version'] : '', ); } } $core = get_site_transient( 'update_core' ); if ( is_object( $core ) && ! empty( $core->updates ) && is_array( $core->updates ) ) { foreach ( $core->updates as $update ) { if ( isset( $update->response ) && 'upgrade' === $update->response && isset( $update->current ) ) { $out['core'] = (string) $update->current; break; } } } return $out; } function drsite_rest_health( WP_REST_Request $request ) { return rest_ensure_response( drsite_collect_health() ); } function drsite_rest_restore_backup( WP_REST_Request $request ) { // Double-check the key here too: permission_callback already ran, but // restore is destructive so never rely on a single layer. $key_check = drsite_rest_check_key( $request ); if ( true !== $key_check ) { return $key_check; } $params = $request->get_json_params(); if ( ! is_array( $params ) || empty( $params ) ) { $params = $request->get_body_params(); } $file = isset( $params['file'] ) ? sanitize_file_name( wp_unslash( $params['file'] ) ) : ''; $confirm = ! empty( $params['confirm'] ) && filter_var( $params['confirm'], FILTER_VALIDATE_BOOLEAN ); if ( '' === $file ) { return new WP_Error( 'drsite_missing_file', __( 'Missing required parameter: file.', 'drsite-backup' ), array( 'status' => 400 ) ); } if ( ! $confirm ) { return new WP_Error( 'drsite_confirm_required', __( 'Restore is destructive. Retry with \"confirm\": true to overwrite files and the database.', 'drsite-backup' ), array( 'status' => 400 ) ); } $path = drsite_resolve_backup_path( $file ); if ( ! $path ) { return new WP_Error( 'drsite_not_found', __( 'Backup file not found.', 'drsite-backup' ), array( 'status' => 404 ) ); } $result = drsite_restore_backup_zip( $path ); if ( is_wp_error( $result ) ) { return new WP_Error( $result->get_error_code() ? $result->get_error_code() : 'drsite_restore_failed', $result->get_error_message(), array( 'status' => 500 ) ); } return rest_ensure_response( array( 'success' => true, 'file' => basename( $path ), 'message' => __( 'Restore complete. Files and database were overwritten from the backup.', 'drsite-backup' ), ) ); }