| 1 |
<?php |
| 2 |
/** |
| 3 |
* StoreEngine full backup — exporter. |
| 4 |
* |
| 5 |
* Streams all StoreEngine data (custom tables + options + CPT posts/meta/terms, |
| 6 |
* optionally WP users and uploaded files) into a single ZIP archive. Memory-safe |
| 7 |
* (batched reads, per-row fwrite) and progress-reporting (pluggable callback for |
| 8 |
* SSE or CLI). |
| 9 |
* |
| 10 |
* @version 1.0.0 |
| 11 |
*/ |
| 12 |
|
| 13 |
namespace StoreEngine\Backup; |
| 14 |
|
| 15 |
if ( ! defined( 'ABSPATH' ) ) { |
| 16 |
exit; |
| 17 |
} |
| 18 |
|
| 19 |
class Exporter { |
| 20 |
|
| 21 |
/** @var array{licensing:bool,logs:bool,users:bool,files:bool} */ |
| 22 |
protected array $opts; |
| 23 |
|
| 24 |
/** @var callable|null function(float $percent, string $message): void */ |
| 25 |
protected $progress; |
| 26 |
|
| 27 |
protected int $batch_size; |
| 28 |
|
| 29 |
protected int $rows_total = 0; |
| 30 |
protected int $rows_done = 0; |
| 31 |
|
| 32 |
/** |
| 33 |
* @param array $opts licensing(bool,true), deployments(bool,false), logs(bool,false), users(bool,false), files(bool,false) |
| 34 |
* @param callable|null $progress function(float $percent, string $message): void |
| 35 |
*/ |
| 36 |
public function __construct( array $opts = [], ?callable $progress = null ) { |
| 37 |
$this->opts = [ |
| 38 |
'licensing' => (bool) ( $opts['licensing'] ?? true ), |
| 39 |
'deployments' => (bool) ( $opts['deployments'] ?? false ), |
| 40 |
'logs' => (bool) ( $opts['logs'] ?? false ), |
| 41 |
'users' => (bool) ( $opts['users'] ?? false ), |
| 42 |
'files' => (bool) ( $opts['files'] ?? false ), |
| 43 |
]; |
| 44 |
$this->progress = $progress; |
| 45 |
$this->batch_size = (int) apply_filters( 'storeengine/backup/batch_size', 2000 ); |
| 46 |
} |
| 47 |
|
| 48 |
protected function report( float $percent, string $message ): void { |
| 49 |
if ( $this->progress ) { |
| 50 |
call_user_func( $this->progress, min( 99.0, round( $percent, 1 ) ), $message ); |
| 51 |
} |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Run the export. Returns the absolute path to the created .zip. |
| 56 |
* |
| 57 |
* @throws \StoreEngine\Classes\Exceptions\StoreEngineException |
| 58 |
*/ |
| 59 |
public function run(): string { |
| 60 |
global $wpdb; |
| 61 |
|
| 62 |
$dir = BackupManager::ensure_backups_dir(); |
| 63 |
$stamp = gmdate( 'Ymd-His' ); |
| 64 |
$rand = wp_generate_password( 8, false ); |
| 65 |
$work = trailingslashit( $dir ) . 'tmp-' . $stamp . '-' . $rand; |
| 66 |
wp_mkdir_p( $work ); |
| 67 |
wp_mkdir_p( $work . '/tables' ); |
| 68 |
|
| 69 |
try { |
| 70 |
$tables = BackupManager::tables_for( $this->opts ); |
| 71 |
|
| 72 |
// Pre-compute total work (rows) for weighted progress. |
| 73 |
$this->rows_total = 1; // avoid div-by-zero |
| 74 |
$table_counts = []; |
| 75 |
foreach ( $tables as $table ) { |
| 76 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier bound via %i in prepare(); row count for export progress over a custom StoreEngine table. |
| 77 |
$count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); |
| 78 |
$table_counts[ $table ] = $count; |
| 79 |
$this->rows_total += $count; |
| 80 |
} |
| 81 |
$post_types = BackupManager::post_types(); |
| 82 |
$post_total = $this->count_posts( $post_types ); |
| 83 |
$this->rows_total += $post_total; |
| 84 |
if ( $this->opts['users'] ) { |
| 85 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 86 |
$this->rows_total += (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ); |
| 87 |
} |
| 88 |
|
| 89 |
$manifest = [ |
| 90 |
'format_version' => BackupManager::FORMAT_VERSION, |
| 91 |
'generated_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), |
| 92 |
'site_url' => site_url(), |
| 93 |
'wp_prefix' => $wpdb->prefix, |
| 94 |
'plugin_version' => defined( 'STOREENGINE_VERSION' ) ? STOREENGINE_VERSION : '', |
| 95 |
'db_version' => defined( 'STOREENGINE_DB_VERSION' ) ? STOREENGINE_DB_VERSION : '', |
| 96 |
'schema_hash' => get_option( 'storeengine_schema_hash', '' ), |
| 97 |
'active_addons' => (array) get_option( 'storeengine_addons', [] ), |
| 98 |
'options' => $this->opts, |
| 99 |
'post_types' => $post_types, |
| 100 |
'includes_users' => $this->opts['users'], |
| 101 |
'includes_files' => $this->opts['files'], |
| 102 |
'excluded' => BackupManager::DENYLIST, |
| 103 |
'tables' => [], |
| 104 |
]; |
| 105 |
|
| 106 |
// 1) Tables. |
| 107 |
foreach ( $tables as $table ) { |
| 108 |
$base = BackupManager::basename( $table ); |
| 109 |
$file = 'tables/' . $base . '.jsonl'; |
| 110 |
$cols = $this->dump_table( $table, $work . '/' . $file ); |
| 111 |
$manifest['tables'][] = [ |
| 112 |
'name' => $base, |
| 113 |
'full_name' => $table, |
| 114 |
'file' => $file, |
| 115 |
'row_count' => $table_counts[ $table ], |
| 116 |
'columns' => $cols, |
| 117 |
'group' => BackupManager::table_group( $table ), |
| 118 |
]; |
| 119 |
} |
| 120 |
|
| 121 |
// 2) Options (raw values; + user_roles when users included). |
| 122 |
$manifest['options_count'] = $this->dump_options( $work . '/options.json' ); |
| 123 |
|
| 124 |
// 3) CPT posts / postmeta / terms. |
| 125 |
$manifest['post_count'] = $this->dump_posts( $post_types, $work ); |
| 126 |
|
| 127 |
// 4) WP users (opt-in). |
| 128 |
if ( $this->opts['users'] ) { |
| 129 |
$manifest['user_count'] = $this->dump_users( $work ); |
| 130 |
} |
| 131 |
|
| 132 |
// Write manifest. |
| 133 |
$this->write_file( $work . '/manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT ) ); |
| 134 |
|
| 135 |
// 5) Zip it. |
| 136 |
$this->report( 99, __( 'Compressing archive…', 'storeengine' ) ); |
| 137 |
$archive = trailingslashit( $dir ) . 'storeengine-backup-' . $stamp . '-' . $rand . '.zip'; |
| 138 |
ArchiveWriter::zip_dir( $work, $archive ); |
| 139 |
|
| 140 |
// 6) Optional uploaded files appended (streamed, no temp copy). |
| 141 |
// The deployment package files (versioned-files) are large and live |
| 142 |
// under their own group, so they're excluded here and appended in |
| 143 |
// (6b) only when the "deployments" group is selected. Both land at |
| 144 |
// the same `files/...` archive path, so restore stays symmetric. |
| 145 |
$backups_real = realpath( BackupManager::backups_dir() ) ?: null; |
| 146 |
$versioned_dir = BackupManager::versioned_files_dir(); |
| 147 |
$versioned_real = $versioned_dir ? ( realpath( $versioned_dir ) ?: null ) : null; |
| 148 |
|
| 149 |
if ( $this->opts['files'] && defined( 'STOREENGINE_SECURE_UPLOADS_DIR' ) ) { |
| 150 |
ArchiveWriter::append_dir( |
| 151 |
$archive, |
| 152 |
STOREENGINE_SECURE_UPLOADS_DIR, |
| 153 |
'files', |
| 154 |
array_filter( [ $backups_real, $versioned_real ] ) |
| 155 |
); |
| 156 |
} |
| 157 |
|
| 158 |
// 6b) Deployment package files — only with the "deployments" group. |
| 159 |
if ( $this->opts['deployments'] && $versioned_dir && is_dir( $versioned_dir ) ) { |
| 160 |
ArchiveWriter::append_dir( |
| 161 |
$archive, |
| 162 |
$versioned_dir, |
| 163 |
'files/' . BackupManager::VERSIONED_FILES_SUBDIR, |
| 164 |
$backups_real |
| 165 |
); |
| 166 |
} |
| 167 |
|
| 168 |
return $archive; |
| 169 |
} finally { |
| 170 |
ArchiveWriter::rrmdir( $work ); |
| 171 |
} |
| 172 |
} |
| 173 |
|
| 174 |
/* -------------------------------------------------------------------- */ |
| 175 |
|
| 176 |
/** |
| 177 |
* Stream one table to a jsonl file in batches. Returns its column list. |
| 178 |
* |
| 179 |
* @return string[] |
| 180 |
*/ |
| 181 |
protected function dump_table( string $table, string $out_path ): array { |
| 182 |
global $wpdb; |
| 183 |
|
| 184 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier bound via %i in prepare(); reading column list of a custom StoreEngine table for export. |
| 185 |
$columns = $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', $table ), 0 ); |
| 186 |
$columns = is_array( $columns ) ? $columns : []; |
| 187 |
|
| 188 |
$handle = $this->open( $out_path ); |
| 189 |
$offset = 0; |
| 190 |
$base = BackupManager::basename( $table ); |
| 191 |
|
| 192 |
do { |
| 193 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier bound via %i and LIMIT/OFFSET via %d in prepare(); batched read of a custom StoreEngine table for streaming export. |
| 194 |
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY 1 LIMIT %d OFFSET %d', $table, $this->batch_size, $offset ), ARRAY_A ); |
| 195 |
if ( ! $rows ) { |
| 196 |
break; |
| 197 |
} |
| 198 |
foreach ( $rows as $row ) { |
| 199 |
fwrite( $handle, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 200 |
} |
| 201 |
$this->rows_done += count( $rows ); |
| 202 |
$offset += $this->batch_size; |
| 203 |
$this->report( |
| 204 |
( $this->rows_done / $this->rows_total ) * 100, |
| 205 |
/* translators: %s table name */ |
| 206 |
sprintf( __( 'Exporting %s…', 'storeengine' ), $base ) |
| 207 |
); |
| 208 |
} while ( count( $rows ) === $this->batch_size ); |
| 209 |
|
| 210 |
fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 211 |
|
| 212 |
return $columns; |
| 213 |
} |
| 214 |
|
| 215 |
protected function dump_options( string $out_path ): int { |
| 216 |
global $wpdb; |
| 217 |
|
| 218 |
$names = []; |
| 219 |
foreach ( BackupManager::option_like_patterns() as $pattern ) { |
| 220 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 221 |
$found = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern ) ); |
| 222 |
$names = array_merge( $names, (array) $found ); |
| 223 |
} |
| 224 |
// Custom roles/caps live here; bundle with the users group. |
| 225 |
if ( $this->opts['users'] ) { |
| 226 |
$names[] = $wpdb->prefix . 'user_roles'; |
| 227 |
} |
| 228 |
$names = array_values( array_unique( $names ) ); |
| 229 |
|
| 230 |
$options = []; |
| 231 |
foreach ( $names as $name ) { |
| 232 |
// Raw value straight from the table — exact serialization round-trip. |
| 233 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 234 |
$value = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) ); |
| 235 |
if ( null !== $value ) { |
| 236 |
$options[ $name ] = $value; |
| 237 |
} |
| 238 |
} |
| 239 |
|
| 240 |
$this->write_file( $out_path, wp_json_encode( $options ) ); |
| 241 |
|
| 242 |
return count( $options ); |
| 243 |
} |
| 244 |
|
| 245 |
protected function count_posts( array $post_types ): int { |
| 246 |
global $wpdb; |
| 247 |
if ( empty( $post_types ) ) { |
| 248 |
return 0; |
| 249 |
} |
| 250 |
$in = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); |
| 251 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %s IN() list interpolated; post types bound via prepare() (placeholders present at runtime); core posts table. |
| 252 |
return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ($in)", $post_types ) ); |
| 253 |
} |
| 254 |
|
| 255 |
protected function dump_posts( array $post_types, string $work ): int { |
| 256 |
global $wpdb; |
| 257 |
if ( empty( $post_types ) ) { |
| 258 |
$this->write_file( $work . '/posts.jsonl', '' ); |
| 259 |
$this->write_file( $work . '/postmeta.jsonl', '' ); |
| 260 |
$this->write_file( $work . '/terms.jsonl', '' ); |
| 261 |
$this->write_file( $work . '/term_taxonomy.jsonl', '' ); |
| 262 |
$this->write_file( $work . '/term_relationships.jsonl', '' ); |
| 263 |
|
| 264 |
return 0; |
| 265 |
} |
| 266 |
|
| 267 |
$in_types = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); |
| 268 |
|
| 269 |
// posts |
| 270 |
$posts_h = $this->open( $work . '/posts.jsonl' ); |
| 271 |
$offset = 0; |
| 272 |
$count = 0; |
| 273 |
do { |
| 274 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic %s IN() list interpolated; post types + LIMIT/OFFSET bound via prepare() (count correct at runtime); core posts table. |
| 275 |
$sql = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_type IN ($in_types) ORDER BY ID LIMIT %d OFFSET %d", array_merge( $post_types, [ $this->batch_size, $offset ] ) ); |
| 276 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql is built via $wpdb->prepare() on the line above; direct read for streaming export. |
| 277 |
$rows = $wpdb->get_results( $sql, ARRAY_A ); |
| 278 |
if ( ! $rows ) { |
| 279 |
break; |
| 280 |
} |
| 281 |
foreach ( $rows as $row ) { |
| 282 |
fwrite( $posts_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 283 |
} |
| 284 |
$count += count( $rows ); |
| 285 |
$this->rows_done += count( $rows ); |
| 286 |
$offset += $this->batch_size; |
| 287 |
$this->report( ( $this->rows_done / $this->rows_total ) * 100, __( 'Exporting posts…', 'storeengine' ) ); |
| 288 |
} while ( count( $rows ) === $this->batch_size ); |
| 289 |
fclose( $posts_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 290 |
|
| 291 |
// postmeta (joined to our post types) |
| 292 |
$meta_h = $this->open( $work . '/postmeta.jsonl' ); |
| 293 |
$offset = 0; |
| 294 |
do { |
| 295 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic %s IN() list interpolated; post types + LIMIT/OFFSET bound via prepare() (count correct at runtime); core postmeta table. |
| 296 |
$sql = $wpdb->prepare( |
| 297 |
"SELECT pm.* FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type IN ($in_types) ORDER BY pm.meta_id LIMIT %d OFFSET %d", |
| 298 |
array_merge( $post_types, [ $this->batch_size, $offset ] ) |
| 299 |
); |
| 300 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber |
| 301 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql is built via $wpdb->prepare() on the line above; direct read for streaming export. |
| 302 |
$rows = $wpdb->get_results( $sql, ARRAY_A ); |
| 303 |
if ( ! $rows ) { |
| 304 |
break; |
| 305 |
} |
| 306 |
foreach ( $rows as $row ) { |
| 307 |
fwrite( $meta_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 308 |
} |
| 309 |
$offset += $this->batch_size; |
| 310 |
} while ( count( $rows ) === $this->batch_size ); |
| 311 |
fclose( $meta_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 312 |
|
| 313 |
// terms / term_taxonomy / term_relationships for our taxonomies + posts |
| 314 |
$taxonomies = []; |
| 315 |
foreach ( $post_types as $pt ) { |
| 316 |
$taxonomies = array_merge( $taxonomies, get_object_taxonomies( $pt ) ); |
| 317 |
} |
| 318 |
$taxonomies = array_values( array_unique( $taxonomies ) ); |
| 319 |
$this->dump_terms( $taxonomies, $post_types, $work ); |
| 320 |
|
| 321 |
return $count; |
| 322 |
} |
| 323 |
|
| 324 |
protected function dump_terms( array $taxonomies, array $post_types, string $work ): void { |
| 325 |
global $wpdb; |
| 326 |
|
| 327 |
$tt_h = $this->open( $work . '/term_taxonomy.jsonl' ); |
| 328 |
$trm_h = $this->open( $work . '/terms.jsonl' ); |
| 329 |
$rel_h = $this->open( $work . '/term_relationships.jsonl' ); |
| 330 |
|
| 331 |
if ( ! empty( $taxonomies ) ) { |
| 332 |
$in_tax = implode( ',', array_fill( 0, count( $taxonomies ), '%s' ) ); |
| 333 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %s IN() list interpolated; taxonomies bound via prepare() (placeholders present at runtime); core term_taxonomy table. |
| 334 |
$tt_rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->term_taxonomy} WHERE taxonomy IN ($in_tax)", $taxonomies ), ARRAY_A ); |
| 335 |
$term_ids = []; |
| 336 |
foreach ( (array) $tt_rows as $row ) { |
| 337 |
fwrite( $tt_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 338 |
$term_ids[ (int) $row['term_id'] ] = true; |
| 339 |
} |
| 340 |
if ( $term_ids ) { |
| 341 |
$ids = implode( ',', array_map( 'intval', array_keys( $term_ids ) ) ); |
| 342 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 343 |
$term_rows = $wpdb->get_results( "SELECT * FROM {$wpdb->terms} WHERE term_id IN ($ids)", ARRAY_A ); |
| 344 |
foreach ( (array) $term_rows as $row ) { |
| 345 |
fwrite( $trm_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 346 |
} |
| 347 |
} |
| 348 |
} |
| 349 |
|
| 350 |
// term_relationships for objects of our post types. |
| 351 |
if ( ! empty( $post_types ) ) { |
| 352 |
$in_types = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); |
| 353 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %s IN() list interpolated; post types bound via prepare() (placeholders present at runtime); core term_relationships table. |
| 354 |
$rel_rows = $wpdb->get_results( $wpdb->prepare( "SELECT tr.* FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id WHERE p.post_type IN ($in_types)", $post_types ), ARRAY_A ); |
| 355 |
foreach ( (array) $rel_rows as $row ) { |
| 356 |
fwrite( $rel_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 357 |
} |
| 358 |
} |
| 359 |
|
| 360 |
fclose( $tt_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 361 |
fclose( $trm_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 362 |
fclose( $rel_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 363 |
} |
| 364 |
|
| 365 |
protected function dump_users( string $work ): int { |
| 366 |
global $wpdb; |
| 367 |
|
| 368 |
$users_h = $this->open( $work . '/users.jsonl' ); |
| 369 |
$meta_h = $this->open( $work . '/usermeta.jsonl' ); |
| 370 |
$offset = 0; |
| 371 |
$count = 0; |
| 372 |
do { |
| 373 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- LIMIT/OFFSET bound via %d in prepare(); batched read of the core users table for streaming export. |
| 374 |
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->users} ORDER BY ID LIMIT %d OFFSET %d", $this->batch_size, $offset ), ARRAY_A ); |
| 375 |
if ( ! $rows ) { |
| 376 |
break; |
| 377 |
} |
| 378 |
$ids = []; |
| 379 |
foreach ( $rows as $row ) { |
| 380 |
fwrite( $users_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 381 |
$ids[] = (int) $row['ID']; |
| 382 |
} |
| 383 |
$id_in = implode( ',', $ids ); |
| 384 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 385 |
$metas = $wpdb->get_results( "SELECT * FROM {$wpdb->usermeta} WHERE user_id IN ($id_in)", ARRAY_A ); |
| 386 |
foreach ( (array) $metas as $m ) { |
| 387 |
fwrite( $meta_h, wp_json_encode( $m ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering. |
| 388 |
} |
| 389 |
$count += count( $rows ); |
| 390 |
$this->rows_done += count( $rows ); |
| 391 |
$offset += $this->batch_size; |
| 392 |
$this->report( ( $this->rows_done / $this->rows_total ) * 100, __( 'Exporting users…', 'storeengine' ) ); |
| 393 |
} while ( count( $rows ) === $this->batch_size ); |
| 394 |
|
| 395 |
fclose( $users_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 396 |
fclose( $meta_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 397 |
|
| 398 |
return $count; |
| 399 |
} |
| 400 |
|
| 401 |
/* -------------------------------------------------------------------- */ |
| 402 |
|
| 403 |
protected function open( string $path ) { |
| 404 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen |
| 405 |
$handle = fopen( $path, 'wb' ); |
| 406 |
if ( ! $handle ) { |
| 407 |
throw new \StoreEngine\Classes\Exceptions\StoreEngineException( 'Unable to open backup work file.', 'backup-open-fail' ); |
| 408 |
} |
| 409 |
|
| 410 |
return $handle; |
| 411 |
} |
| 412 |
|
| 413 |
protected function write_file( string $path, string $contents ): void { |
| 414 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents |
| 415 |
file_put_contents( $path, $contents ); |
| 416 |
} |
| 417 |
} |
| 418 |
|
| 419 |
// End of file exporter.php. |
| 420 |
|