\nRequire all denied\n\n\nOrder allow,deny\nDeny from all\n\n" ); } if ( ! is_file( $dir . '/index.html' ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors @file_put_contents( $dir . '/index.html', '' ); } } /** * Resolve an archive name to a safe absolute path inside the backups dir. * * @param string $name Filename (basename enforced). * @return string Absolute path, or '' for an invalid name. */ public static function archive_path( string $name ): string { $name = sanitize_file_name( $name ); if ( '' === $name || self::EXT !== substr( $name, -5 ) || false !== strpos( $name, '/' ) || false !== strpos( $name, '..' ) ) { return ''; } $path = wp_normalize_path( self::backup_dir() . '/' . $name ); if ( is_file( $path ) ) { return $path; } return ''; } /** * Create a .emcp archive: DB dump + manifest, plus optional site files. * * Scope (back-compat: omitting every scope option keeps the current full * behaviour — full DB dump + optional full file set — and readers treat an * archive without a `scope` manifest key as full). * * @param string $name Optional archive name ('.emcp' appended if missing). * @param array $opts { * @type string|array $kind 'backup' (default) or 'sync' (tag only). * @type string|array $db 'all' (default), 'none', or an array of * table names to dump (empty array = 'none'). * @type bool $include_files Include uploads/plugins/themes (legacy; * equivalent to file_roots = 'all'). * @type string|array $file_roots 'all' | 'none' | an array of absolute dirs * (or a token => absolute-dir map) to bundle * under files/. * } * @return string|false Absolute archive path, or false on failure. */ public static function create_archive( string $name = '', array $opts = array() ) { if ( ! class_exists( 'ZipArchive' ) ) { return false; } $kind = ( 'sync' === ( $opts['kind'] ?? '' ) ) ? 'sync' : 'backup'; // DB scope: 'all' | 'none' | table allowlist. $tables = null; // null = full dump. $scope_db = 'all'; if ( array_key_exists( 'db', $opts ) ) { $db = $opts['db']; if ( is_array( $db ) ) { $tables = array_values( array_unique( array_filter( array_map( 'strval', $db ) ) ) ); if ( $tables ) { $scope_db = $tables; } else { $tables = null; // No tables chosen — treated as none. $scope_db = 'none'; } } elseif ( 'none' === $db ) { $scope_db = 'none'; } } // File scope: 'all' | 'none' | token => absolute-dir map. $file_roots = null; // null = no files. if ( array_key_exists( 'file_roots', $opts ) ) { $file_roots = $opts['file_roots']; } elseif ( ! empty( $opts['include_files'] ) ) { $file_roots = 'all'; // Legacy flag ≈ full file set. } $want_files = false; $scope_files = 'none'; $roots_map = array(); if ( 'all' === $file_roots ) { $want_files = true; $scope_files = 'all'; } elseif ( is_array( $file_roots ) && $file_roots ) { $want_files = true; foreach ( $file_roots as $key => $path ) { if ( ! is_string( $path ) || '' === $path ) { continue; } $roots_map[ is_int( $key ) ? self::root_key( $path ) : (string) $key ] = $path; } $scope_files = array_keys( $roots_map ); } $dir = self::backup_dir(); $name = sanitize_file_name( $name ); if ( '' === $name ) { // Random suffix so the filename is not date-guessable on the web. $name = ( 'sync' === $kind ? 'sync-' : 'backup-' ) . gmdate( 'Y-m-d-His' ) . '-' . wp_generate_password( 6, false ); } if ( self::EXT !== substr( $name, -5 ) ) { $name .= self::EXT; } $zip_file = $dir . '/' . $name; $tmp_sql = $dir . '/.tmp-' . wp_generate_password( 8, false ) . '.sql'; $has_db = ( 'none' !== $scope_db ); $db_stats = null; if ( $has_db ) { $db_stats = is_array( $tables ) ? EMCP_Tools_DB_Exporter::export_to_file( $tmp_sql, $tables ) : EMCP_Tools_DB_Exporter::export_to_file( $tmp_sql ); if ( false === $db_stats || ! is_file( $tmp_sql ) ) { @unlink( $tmp_sql ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- local temp cleanup. return false; } } $zip = new ZipArchive(); if ( true !== $zip->open( $zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) { if ( $has_db ) { @unlink( $tmp_sql ); // phpcs:ignore WordPress.WP.AlternativeFunctions } return false; } $ok = true; if ( $has_db ) { $ok = ( true === $zip->addFile( $tmp_sql, 'database.sql' ) ); } $file_entries = array(); if ( $ok && $want_files ) { $file_entries = self::add_files( $zip, $roots_map ); $ok = is_array( $file_entries ); // add_files returns false on a failed addFile. } $manifest = array( 'emcp' => true, 'format_version' => self::FORMAT_VERSION, 'version' => defined( 'EMCP_TOOLS_VERSION' ) ? EMCP_TOOLS_VERSION : '0', 'kind' => $kind, 'scope' => array( 'db' => $scope_db, 'files' => $scope_files, ), 'created_at' => gmdate( 'c' ), 'site_url' => home_url(), 'siteurl_option' => get_option( 'siteurl', home_url() ), 'php' => PHP_VERSION, 'wp' => isset( $GLOBALS['wp_version'] ) ? $GLOBALS['wp_version'] : '', 'db' => self::db_version(), ); if ( $has_db ) { $manifest['db_stats'] = array( 'tables' => isset( $db_stats['tables'] ) ? $db_stats['tables'] : 0, 'rows' => isset( $db_stats['rows'] ) ? $db_stats['rows'] : 0, 'per_table' => isset( $db_stats['per_table'] ) ? $db_stats['per_table'] : array(), 'partial' => ! empty( $db_stats['partial'] ), ); $manifest['database_sha256'] = hash_file( 'sha256', $tmp_sql ); // Source DB prefix so a restore can refuse cleanly when it differs // from the destination (imports write literal table names). $manifest['db_prefix'] = self::site_db_prefix(); } $manifest['files'] = count( $file_entries ); $manifest['file_roots'] = $file_entries; if ( $ok ) { $ok = ( false !== $zip->addFromString( 'manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ) ); } $closed = $zip->close(); if ( $has_db ) { @unlink( $tmp_sql ); // phpcs:ignore WordPress.WP.AlternativeFunctions } if ( ! $ok || true !== $closed ) { @unlink( $zip_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- remove the partial archive. return false; } return is_file( $zip_file ) ? $zip_file : false; } /** * Read + parse the manifest inside an archive. * * @param string $name Archive filename. * @return array|null Manifest, or null when unreadable/invalid. */ public static function read_manifest( string $name ) { $path = self::archive_path( $name ); if ( '' === $path ) { return null; } $zip = new ZipArchive(); if ( true !== $zip->open( $path ) ) { return null; } $json = $zip->getFromName( 'manifest.json' ); $zip->close(); if ( false === $json ) { return null; } $manifest = json_decode( $json, true ); return is_array( $manifest ) ? $manifest : null; } /** * Delete an archive by filename. Admin-only callers. * * @param string $name Archive filename. * @return bool */ public static function delete_archive( string $name ): bool { $path = self::archive_path( $name ); if ( '' === $path ) { return false; } return @unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- file deletion. } /** * List existing archives with metadata. * * @return array[] */ public static function list_archives(): array { $dir = self::backup_dir(); $files = glob( $dir . '/*' . self::EXT ) ?: array(); // phpcs:ignore WordPress.WP.AlternativeFunctions $out = array(); foreach ( $files as $f ) { $size = filesize( $f ); $out[] = array( 'filename' => basename( $f ), 'path' => $f, 'size' => size_format( $size ), 'size_bytes' => (int) $size, 'date' => gmdate( 'Y-m-d H:i:s', filemtime( $f ) ), // No sha256 here: hashing every archive reads its full contents on // a plain listing. Integrity lives in the manifest hash checked at // restore time (and in the per-archive hash when one is requested). ); } usort( $out, static function ( $a, $b ) { return strcmp( $b['filename'], $a['filename'] ); // Newest (lexical timestamp) first. } ); return $out; } /** * Add site files to the open archive under files/. * * With no roots given (or an empty map) the canonical three roots are walked * (uploads/plugins/themes — the legacy full set). A keyed token => absolute-dir * map limits the walk to exactly those roots (selective sync). Paths are * normalized before the WP_CONTENT_DIR prefix is stripped (a Windows host * writes backslash paths from RecursiveDirectoryIterator, which would defeat * the prefix match otherwise). Never descends into the plugin itself, the * backups dir, the sandbox, or caches. * * @param \ZipArchive $zip Open archive. * @param array $roots Token => absolute dir map (empty = the three roots). * @return string[]|false Root → file-count map, or false when an addFile fails. */ private static function add_files( ZipArchive $zip, array $roots = array() ) { $content_dir = wp_normalize_path( ( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content' ) ); if ( empty( $roots ) ) { $roots = array( 'uploads' => self::uploads_basedir(), 'plugins' => wp_normalize_path( defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : $content_dir . '/plugins' ), 'themes' => wp_normalize_path( get_theme_root() ? get_theme_root() : $content_dir . '/themes' ), ); } $skip_dirs = array( wp_normalize_path( self::backup_dir() ), wp_normalize_path( defined( 'EMCP_TOOLS_DIR' ) ? EMCP_TOOLS_DIR : '' ), $content_dir . '/emcp-sandbox', $content_dir . '/cache', $content_dir . '/uploads/emcp-sandbox', ); $added = array(); foreach ( $roots as $root_name => $root ) { $count = self::add_root_files( $zip, (string) $root, $skip_dirs, $content_dir ); if ( false === $count ) { return false; // addFile failed — never report a partial bundle as complete. } if ( $count > 0 ) { $added[ (string) $root_name ] = $count; } } return $added; } /** * Walk one root into the archive under files/, pruning the skip directories. * * @param \ZipArchive $zip Open archive. * @param string $root Absolute root directory (normalized later). * @param string[] $skip_dirs Directories never descended into. * @param string $content_dir Normalized wp-content path. * @return int|false Files added, or false when an addFile failed. */ private static function add_root_files( ZipArchive $zip, string $root, array $skip_dirs, string $content_dir ) { $root = wp_normalize_path( $root ); if ( ! is_dir( $root ) ) { return 0; } $count = 0; $it = self::file_iterator( $root, $skip_dirs ); foreach ( $it as $file ) { if ( ! $file->isFile() ) { continue; } $local = wp_normalize_path( $file->getPathname() ); // Resolve symlinks and refuse anything that escapes wp-content — a // scoped root could otherwise exfiltrate a linked target outside it. $real = realpath( $local ); // phpcs:ignore WordPress.WP.AlternativeFunctions if ( false === $real ) { continue; } if ( 0 !== strpos( wp_normalize_path( $real ), $content_dir . '/' ) ) { continue; } $zip_name = self::zip_relative_name( $local, $content_dir ); if ( '' === $zip_name ) { continue; // Outside wp-content — never bundled. } if ( ! $zip->addFile( $local, $zip_name ) ) { return false; // Propagate failure so create_archive cannot report a complete bundle. } $count++; } return $count; } /** * Recursive file iterator over a root, pruning excluded directories. * * @param string $root Root directory (normalized). * @param string[] $skip_dirs Directories never descended into. * @return \RecursiveIteratorIterator */ private static function file_iterator( string $root, array $skip_dirs ) { return new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), static function ( $current ) use ( $skip_dirs ): bool { if ( $current->isDir() ) { return ! self::dir_is_skipped( $current->getPathname(), $skip_dirs ); } return true; } ), RecursiveIteratorIterator::LEAVES_ONLY ); } /** * Whether a directory is inside an excluded path. * * @param string $path Normalized path. * @param string[] $skip_dirs Excluded roots. * @return bool */ private static function dir_is_skipped( string $path, array $skip_dirs ): bool { $path = wp_normalize_path( $path ); foreach ( $skip_dirs as $skip ) { if ( '' !== $skip && ( $path === $skip || 0 === strpos( $path, $skip . '/' ) ) ) { return true; } } return false; } /** * Archive name for a file: files/, or '' when the file * is outside wp-content. * * @param string $local Normalized absolute path. * @param string $content_dir Normalized wp-content path. * @return string */ private static function zip_relative_name( string $local, string $content_dir ): string { if ( 0 !== strpos( $local, $content_dir . '/' ) ) { return ''; } return 'files/' . ltrim( substr( $local, strlen( $content_dir ) ), '/' ); } /** * Uploads base dir (normalized). * * @return string */ private static function uploads_basedir(): string { $upload = wp_upload_dir(); return wp_normalize_path( $upload['basedir'] ); } /** * Stable manifest key for an absolute file root: its wp-content-relative path * with non-alphanumerics collapsed (uploads → 'uploads', plugins/foo → 'plugins-foo'). * * @param string $abs Normalized absolute directory. * @return string */ private static function root_key( string $abs ): string { $content_dir = wp_normalize_path( ( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content' ) ); $rel = $abs; if ( 0 === strpos( $abs, $content_dir . '/' ) ) { $rel = substr( $abs, strlen( $content_dir ) ); } $rel = trim( $rel, '/' ); if ( '' === $rel ) { return 'content'; } $rel = preg_replace( '/[^a-zA-Z0-9]+/', '-', $rel ); $rel = trim( (string) $rel, '-' ); return '' !== $rel ? $rel : 'root'; } /** * MySQL server version via wpdb when available. * * @return string */ private static function db_version(): string { global $wpdb; if ( isset( $wpdb ) && is_callable( array( $wpdb, 'db_version' ) ) ) { return (string) $wpdb->db_version(); } return ''; } /** * The site's DB table prefix (recorded in the manifest for cross-site guards). * * @return string */ private static function site_db_prefix(): string { global $wpdb; return ( isset( $wpdb ) && isset( $wpdb->prefix ) ) ? (string) $wpdb->prefix : ''; } }