$rank ) {
$rank = $this_rank;
$best = $m[1];
}
}
}
return $best;
}
/**
* Does the folder carry one of our protection files?
*
* @param string $path Folder.
* @return bool
*/
private static function is_ours( $path ) {
// index.php: folders made before D70 may still carry only that one.
foreach ( array( '.htaccess', 'web.config', 'index.php' ) as $name ) {
$file = $path . '/' . $name;
if ( is_file( $file ) && false !== strpos( (string) @file_get_contents( $file, false, null, 0, 512 ), 'Database Export Genie' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
return true;
}
}
return false;
}
/**
* Resolve (and cache) the backup directory path. Doesn't create it.
*
* @return string Normalized absolute path without trailing slash.
*/
public static function dir() {
if ( null !== self::$dir ) {
return self::$dir;
}
if ( defined( 'DBMG_BACKUP_DIR' ) && is_string( DBMG_BACKUP_DIR ) && '' !== DBMG_BACKUP_DIR ) {
self::$dir = untrailingslashit( wp_normalize_path( DBMG_BACKUP_DIR ) );
return self::$dir;
}
$name = 'dbmg-backups-' . self::suffix();
$bases = self::base_dirs();
// An existing folder wins wherever it is (an adopted one may live in uploads); otherwise the first writable base.
foreach ( $bases as $base ) {
if ( is_dir( $base . '/' . $name ) ) {
self::$dir = $base . '/' . $name;
return self::$dir;
}
}
foreach ( $bases as $base ) {
if ( wp_is_writable( $base ) ) {
self::$dir = $base . '/' . $name;
return self::$dir;
}
}
self::$dir = $bases[0] . '/' . $name;
return self::$dir;
}
/**
* Forget the cached path (after delete-all).
*/
public static function reset() {
self::$dir = null;
}
/**
* Create the folder and all protection files (§12.2).
*
* @return string Directory.
* @throws Backup_Exception When it can't be created or written.
*/
public static function ensure() {
$dir = self::dir();
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
throw new Backup_Exception( 'dir_not_writable', 'mkdir failed: ' . $dir, array( 'path' => $dir ) );
}
if ( ! wp_is_writable( $dir ) ) {
throw new Backup_Exception( 'dir_not_writable', 'not writable: ' . $dir, array( 'path' => $dir ) );
}
self::protect();
return $dir;
}
/**
* Web-server rules for the backup folder. Every layer fails closed: a directive the server doesn't
* allow in .htaccess/web.config turns into a 500 for the folder, which still serves nothing.
*
* @return array file name => content.
*/
public static function protection_files() {
return array(
// Apache 2.4 (mod_authz_core), Apache 2.2 (mod_access), LiteSpeed. OpenLiteSpeed ignores access
// directives in .htaccess but applies rewrite rules, hence the [F] rule. No "Options -Indexes": the
// deny already stops listings, and under a restricted AllowOverride it only turns 403s into 500s.
'.htaccess' => self::PROTECT_MARK . "\n"
. "# Downloads go through wp-admin with a login check; nothing in this folder is served directly.\n"
. "\n Require all denied\n\n"
. "\n Order deny,allow\n Deny from all\n\n"
. "\n RewriteEngine On\n RewriteRule ^ - [F,L]\n\n",
// IIS: request filtering ships with IIS 7+ and refuses every extension; URL authorization denies
// everyone where that module is installed.
'web.config' => "\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n\n",
// Any server with autoindex on (Nginx, Caddy): an empty index page instead of a listing.
// DECISION (D70): no index.php. wp.org doesn't allow a plugin to write executable PHP into a data folder;
// the empty index.html gives the same "no listing" layer (the deny rules and 0600 files do the rest).
'index.html' => '',
);
}
/**
* Write missing protection files and upgrade ones an older version wrote. Files without our marker
* belong to the site owner and are left alone. Runs on every page load, so a plugin update or a
* deleted .htaccess heals itself.
*
* @return bool True when something was (re)written.
*/
public static function protect() {
$dir = self::dir();
if ( ! is_dir( $dir ) ) {
return false;
}
$changed = false;
foreach ( self::protection_files() as $name => $content ) {
$path = $dir . '/' . $name;
$current = is_file( $path ) ? (string) @file_get_contents( $path ) : null; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( $current === $content ) {
continue;
}
// DECISION: rewrite only our own files: missing, empty placeholder, or carrying a Database Export Genie marker (D26).
$ours = null === $current || '' === $current || false !== strpos( $current, 'Database Export Genie' );
if ( $ours && false !== @file_put_contents( $path, $content ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
$changed = true;
}
}
// Earlier versions wrote an index.php (404) here; take ours away (only a file carrying our marker).
$legacy = $dir . '/index.php';
if ( is_file( $legacy ) && false !== strpos( (string) @file_get_contents( $legacy, false, null, 0, 512 ), 'Database Export Genie' ) && @unlink( $legacy ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,WordPress.WP.AlternativeFunctions.unlink_unlink
$changed = true;
}
if ( $changed ) {
self::forget_public_status();
}
return $changed;
}
/**
* Owner-only permissions for a file that holds backup data (§12.2 hardening, D26).
*
* Where the web server runs as a different user than PHP (most panel and managed Nginx setups), it
* can't read the file even if it would otherwise serve the folder. Downloads are streamed by PHP, so
* nothing else needs to read it. Filter `dbmg_file_mode` (e.g. 0640) for X-Sendfile/X-Accel setups.
*
* @param string $path File.
*/
public static function lock_down( $path ) {
$mode = (int) apply_filters( 'dbmg_file_mode', 0600 );
if ( $mode > 0 && $mode <= 0777 ) {
@chmod( $path, $mode ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_chmod
}
}
/**
* Resolve a file name inside the backup dir, refusing anything that escapes it.
*
* @param string $name Base name.
* @return string|null
*/
public static function path( $name ) {
if ( '' === $name || false !== strpos( $name, '/' ) || false !== strpos( $name, '\\' ) || false !== strpos( $name, '..' ) ) {
return null;
}
return self::dir() . '/' . $name;
}
/**
* Work folder for a job.
*
* @param int $id Job id.
* @param string $token Job token.
* @return string
*/
public static function work_dir( $id, $token ) {
return self::dir() . '/work-' . (int) $id . '-' . preg_replace( '/[^A-Za-z0-9]/', '', $token );
}
/**
* Create a job's work folder with an empty index, so even a server with directory listing on
* and no rule for this folder shows nothing.
*
* @param string $path Work dir.
* @return bool
*/
public static function make_work_dir( $path ) {
if ( ! is_dir( $path ) && ! wp_mkdir_p( $path ) ) {
return false;
}
if ( ! file_exists( $path . '/index.html' ) ) {
@file_put_contents( $path . '/index.html', '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
}
return true;
}
/**
* Remove work folders left by jobs that no longer exist (killed site, restored database, deleted option).
* Only one job can exist, so anything but $keep is garbage.
*
* @param string $keep Work dir of the active job, or ''.
*/
public static function cleanup_orphans( $keep = '' ) {
$dir = self::dir();
foreach ( (array) @scandir( $dir ) as $item ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( 0 === strpos( $item, 'work-' ) && $dir . '/' . $item !== $keep && is_dir( $dir . '/' . $item ) ) {
self::rrmdir( $dir . '/' . $item );
}
}
}
/**
* Write JSON atomically (tmp + rename, §6.2).
*
* @param string $path File.
* @param mixed $data Data.
* @throws Disk_Full_Exception When it can't be written.
*/
public static function write_json( $path, $data ) {
$json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
$tmp = $path . '.tmp';
$w = file_put_contents( $tmp, $json ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
if ( strlen( $json ) !== $w ) {
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.unlink_unlink
throw new Disk_Full_Exception( 'Could not write ' . basename( $path ) );
}
self::lock_down( $tmp );
if ( ! @rename( $tmp, $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.rename_rename
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.unlink_unlink
rename( $tmp, $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
}
}
/**
* @param string $path File.
* @return array|null
*/
public static function read_json( $path ) {
if ( ! is_readable( $path ) ) {
return null;
}
$data = json_decode( (string) file_get_contents( $path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
return is_array( $data ) ? $data : null;
}
/**
* Save a finished/failed/cancelled manifest.
*
* @param array $manifest Manifest (§6.2).
*/
public static function save_manifest( array $manifest ) {
self::ensure();
self::write_json( self::dir() . '/' . (int) $manifest['id'] . '-' . $manifest['token'] . '.json', $manifest );
}
/**
* All manifests, newest first. Cancelled ones older than 24 h are cleaned up (§8.6).
*
* @return array[]
*/
public static function manifests() {
$dir = self::dir();
if ( ! is_dir( $dir ) ) {
return array();
}
$out = array();
foreach ( (array) scandir( $dir ) as $file ) {
if ( ! preg_match( self::FILE_PATTERN, $file, $m ) || 'json' !== $m[3] ) {
continue;
}
$data = self::read_json( $dir . '/' . $file );
if ( ! $data || (int) $data['id'] !== (int) $m[1] || (string) $data['token'] !== $m[2] ) {
continue;
}
if ( 'cancelled' === $data['status'] && ! empty( $data['completed_at_utc'] ) && ( time() - strtotime( $data['completed_at_utc'] ) ) > DAY_IN_SECONDS ) {
self::delete( (int) $data['id'] );
continue;
}
if ( 'ready' === $data['status'] ) {
$zip = $dir . '/' . $data['zip_file'];
if ( ! is_file( $zip ) ) {
$data['status'] = 'missing';
}
}
$out[] = $data;
}
usort(
$out,
static function ( $a, $b ) {
return (int) $b['id'] - (int) $a['id'];
}
);
return $out;
}
/**
* @param int $id Backup id.
* @return array|null
*/
public static function find( $id ) {
foreach ( self::manifests() as $m ) {
if ( (int) $m['id'] === (int) $id ) {
return $m;
}
}
return null;
}
/**
* Absolute zip path for a ready manifest, verified to be inside our folder.
*
* @param array $manifest Manifest.
* @return string|null
*/
public static function zip_path( array $manifest ) {
if ( empty( $manifest['zip_file'] ) || ! preg_match( self::FILE_PATTERN, $manifest['zip_file'] ) ) {
return null;
}
$path = self::path( $manifest['zip_file'] );
$real = $path ? realpath( $path ) : false;
$base = realpath( self::dir() );
if ( ! $real || ! $base || 0 !== strpos( wp_normalize_path( $real ), trailingslashit( wp_normalize_path( $base ) ) ) ) {
return null;
}
return $real;
}
/**
* Finished backups whose zip is on disk, newest first. Read-only; used by Database Import Genie's
* Import tab to offer them as import sources.
*
* @return array[] id, name, created (UTC), size, zip (absolute path).
*/
public static function ready_backups() {
$out = array();
foreach ( self::manifests() as $m ) {
if ( 'ready' !== $m['status'] ) {
continue;
}
$zip = self::zip_path( $m );
if ( ! $zip ) {
continue;
}
$out[] = array(
'id' => (int) $m['id'],
'name' => (string) $m['name'],
'created' => (string) $m['created_at_utc'],
'size' => (int) $m['zip_bytes'],
'zip' => $zip,
);
}
return $out;
}
/**
* Delete zip + manifest for one id.
*
* @param int $id Backup id.
* @return bool
*/
public static function delete( $id ) {
$dir = self::dir();
$deleted = false;
foreach ( (array) @scandir( $dir ) as $file ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( preg_match( self::FILE_PATTERN, $file, $m ) && (int) $m[1] === (int) $id ) {
$deleted = @unlink( $dir . '/' . $file ) || $deleted; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.unlink_unlink
}
}
return $deleted;
}
/**
* Remove a directory we own, recursively. Refuses paths outside the backup folder.
*
* @param string $path Directory.
*/
public static function rrmdir( $path ) {
$base = self::dir();
$path = untrailingslashit( wp_normalize_path( $path ) );
if ( $path !== $base && 0 !== strpos( $path, $base . '/' ) ) {
return;
}
if ( ! is_dir( $path ) ) {
return;
}
foreach ( (array) scandir( $path ) as $item ) {
if ( '.' === $item || '..' === $item ) {
continue;
}
$full = $path . '/' . $item;
if ( is_dir( $full ) && ! is_link( $full ) ) {
self::rrmdir( $full );
} else {
@unlink( $full ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.unlink_unlink
}
}
@rmdir( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
}
/**
* Delete every backup, work folder and the folder itself; a fresh random name is used next time (§13).
* With DBMG_BACKUP_DIR only the contents are removed — that folder belongs to the site owner.
*/
public static function delete_all() {
$dir = self::dir();
if ( defined( 'DBMG_BACKUP_DIR' ) && DBMG_BACKUP_DIR ) {
foreach ( (array) @scandir( $dir ) as $item ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( preg_match( self::FILE_PATTERN, $item ) || 0 === strpos( $item, 'work-' ) || 0 === strpos( $item, 'dbmg-probe-' ) ) {
$full = $dir . '/' . $item;
if ( is_dir( $full ) ) {
self::rrmdir( $full );
} else {
@unlink( $full ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.unlink_unlink
}
}
}
} else {
self::rrmdir( $dir );
Options::delete( self::SUFFIX_OPTION );
}
self::reset();
self::forget_public_status();
}
/**
* Count + total size of finished backups.
*
* @return array [ count, bytes ]
*/
public static function usage() {
$count = 0;
$bytes = 0;
foreach ( self::manifests() as $m ) {
++$count;
if ( 'ready' === $m['status'] ) {
$bytes += (int) $m['zip_bytes'];
}
}
return array( $count, $bytes );
}
/**
* Folder path for display: relative to ABSPATH when inside it.
*
* @return string
*/
public static function display_path() {
$dir = self::dir();
$root = untrailingslashit( wp_normalize_path( ABSPATH ) );
if ( 0 === strpos( $dir, $root . '/' ) ) {
return substr( $dir, strlen( $root ) + 1 );
}
return $dir;
}
/**
* Public URL of the backup folder if it sits under the content or uploads dir.
*
* @return string|null
*/
private static function dir_url() {
$dir = self::dir();
$content = untrailingslashit( wp_normalize_path( WP_CONTENT_DIR ) );
if ( 0 === strpos( $dir, $content . '/' ) ) {
return content_url( substr( $dir, strlen( $content ) + 1 ) );
}
$uploads = wp_upload_dir( null, false );
$basedir = untrailingslashit( wp_normalize_path( $uploads['basedir'] ) );
if ( 0 === strpos( $dir, $basedir . '/' ) ) {
return trailingslashit( $uploads['baseurl'] ) . substr( $dir, strlen( $basedir ) + 1 );
}
$root = untrailingslashit( wp_normalize_path( ABSPATH ) );
if ( 0 === strpos( $dir, $root . '/' ) ) {
return site_url( substr( $dir, strlen( $root ) + 1 ) );
}
return null;
}
/**
* URL path that every backup URL starts with, for a web-server rule that survives "Delete all"
* (which picks a new random folder name). Null when the folder has no URL.
*
* @return string|null e.g. "/wp-content/dbmg-backups-".
*/
public static function url_path_prefix() {
$url = self::dir_url();
if ( null === $url ) {
return null;
}
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
if ( ! defined( 'DBMG_BACKUP_DIR' ) || ! DBMG_BACKUP_DIR ) {
$path = preg_replace( '/dbmg-backups-[a-z0-9]{16}$/', 'dbmg-backups-', $path );
}
return '/' . ltrim( $path, '/' );
}
/**
* A folder outside the web root that PHP can write to, to suggest for DBMG_BACKUP_DIR. Only offered
* when it's provably outside the document root; otherwise the page gives the generic advice.
*
* @return string|null
*/
public static function private_dir_suggestion() {
$root = isset( $_SERVER['DOCUMENT_ROOT'] ) ? realpath( sanitize_text_field( wp_unslash( $_SERVER['DOCUMENT_ROOT'] ) ) ) : false;
$site = realpath( ABSPATH );
if ( ! $root || ! $site ) {
return null;
}
$root = untrailingslashit( wp_normalize_path( $root ) );
$site = untrailingslashit( wp_normalize_path( $site ) );
$parent = untrailingslashit( wp_normalize_path( dirname( $root ) ) );
if ( ( $site !== $root && 0 !== strpos( $site, $root . '/' ) ) || '' === $parent || '/' === $parent ) {
return null;
}
// The parent may itself be another site's document root (cPanel addon domains inside public_html).
foreach ( array( 'index.php', 'index.html', 'index.htm', '.htaccess', 'wp-config.php', 'web.config' ) as $marker ) {
if ( @file_exists( $parent . '/' . $marker ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- open_basedir may forbid the check.
return null;
}
}
if ( ! @is_writable( $parent ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- open_basedir may forbid the check.
return null;
}
return $parent . '/dbmg-backups';
}
/**
* Drop the cached web-access result (folder changed or its rules were rewritten).
*/
public static function forget_public_status() {
$cache = Environment::cache();
if ( isset( $cache['public'] ) ) {
unset( $cache['public'], $cache['public_time'] );
Options::set( Environment::CACHE_OPTION, $cache );
}
}
/**
* Is the folder readable from the web? (§12.3) public | protected | unknown.
*
* Probes with the same extensions and permissions backups use (.zip, the .sql work parts, the .json
* manifests), because Nginx/CDN rules often match by extension. "protected" is cached 12 h;
* "public" and "unknown" are re-checked every 10 min so a fix shows up on the next page load.
*
* @param bool $force Skip cache.
* @return string
*/
public static function public_status( $force = false ) {
$cache = Environment::cache();
if ( ! $force && isset( $cache['public'] ) ) {
$age = time() - ( isset( $cache['public_time'] ) ? (int) $cache['public_time'] : 0 );
if ( 'protected' === $cache['public'] || $age < self::RECHECK_AFTER ) {
return $cache['public'];
}
}
$url = self::dir_url();
if ( null === $url ) {
$status = 'protected'; // Outside the web root.
} else {
try {
self::ensure();
} catch ( Backup_Exception $e ) {
return 'unknown';
}
$status = 'protected';
foreach ( array( 'zip', 'sql', 'json' ) as $i => $ext ) {
$result = self::probe( $url, $ext );
if ( 'public' === $result || ( 'unknown' === $result && 0 === $i ) ) {
$status = $result; // Readable, or this site can't reach itself at all.
break;
}
}
}
Environment::cache_set(
array(
'public' => $status,
'public_time' => time(),
)
);
return $status;
}
/**
* Put one random file in the folder and try to fetch it over HTTP.
*
* @param string $url Folder URL.
* @param string $ext Extension to test.
* @return string public | protected | unknown
*/
private static function probe( $url, $ext ) {
$name = 'dbmg-probe-' . strtolower( wp_generate_password( 10, false ) ) . '.' . $ext;
$file = self::dir() . '/' . $name;
$token = wp_generate_password( 24, false );
if ( ! file_put_contents( $file, $token ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
return 'unknown';
}
self::lock_down( $file );
$response = wp_remote_get(
trailingslashit( $url ) . $name,
array(
'timeout' => 5,
'redirection' => 3,
'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter, used as core's own loopback requests do.
)
);
@unlink( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.unlink_unlink
if ( is_wp_error( $response ) ) {
return 'unknown';
}
if ( false !== strpos( (string) wp_remote_retrieve_body( $response ), $token ) ) {
return 'public'; // Any status: a 404 page that echoes the file is still a leak.
}
return 'protected';
}
}