$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'; } }