\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 '
';
} );
/* ------------------------------------------------------------------------- */
/* 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' ),
)
);
}