PluginProbe
UpdraftPlus: WP Backup & Migration Plugin / 1.26.7
UpdraftPlus: WP Backup & Migration Plugin v1.26.7
1.26.7 1.26.6 1.26.5 1.26.4 1.26.3 1.9.19 1.9.25 1.9.26 1.9.30 1.9.31 1.9.32 1.9.4 1.9.40 1.9.41 1.9.42 1.9.43 1.9.44 1.9.45 1.9.46 1.9.5 1.9.50 1.9.51 1.9.60 1.9.62 1.9.63 All 371 releases
updraftplus / backup.php

backup.php in UpdraftPlus: WP Backup & Migration Plugin 1.26.7, at backup.php

4,773 lines 233.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- we try to reduce overhead by bypassing WP APIs and other extra layers; Some custom complex queries tailored specifically to our needs, giving us full control over the SQL commands and data manipulation
3 // phpcs:disable WordPress.WP.AlternativeFunctions.rename_rename -- rename() usage is intentional and safe within this context
4 // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.file_system_operations_fgets, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_operations_mkdir, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.WP.AlternativeFunctions.file_system_operations_fputs, WordPress.WP.AlternativeFunctions.file_system_operations_is_writeable, WordPress.WP.AlternativeFunctions.file_system_operations_chown, WordPress.WP.AlternativeFunctions.file_system_operations_chgrp, WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Native PHP fileystem function is used for direct control and performance because it can bypass additional layers of abstraction so that no overhead from the WordPress filesystem API's internal handling
5 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- some query operations need to always receive the most up-to-date or actual data directly from the database, reducing the risk of serving stale information.
6 // phpcs:disable Squiz.PHP.DiscouragedFunctions.Discouraged -- some functions, like set_time_limit() and ini_set(), are used to temporarily change PHP configuration values based on the script's needs (e.g., processing large datasets or performing long operations).
7 if (!defined('ABSPATH')) die('No direct access allowed');
8
9 if (!class_exists('UpdraftPlus_PclZip')) updraft_try_include_file('includes/class-zip.php', 'require_once');
10
11 /**
12 * This file contains code that is only needed/loaded when a backup is running
13 */
14 class UpdraftPlus_Backup {
15
16 private $index = 0;
17
18 private $manifest_path;
19
20 private $zipfiles_added;
21
22 private $zipfiles_added_thisrun = 0;
23
24 public $zipfiles_dirbatched;
25
26 public $zipfiles_batched;
27
28 public $zipfiles_skipped_notaltered;
29
30 private $symlink_reversals = array();
31
32 private $makezip_recursive_batchedbytes;
33
34 private $zip_split_every = 419430400; // 400MB
35
36 private $zip_last_ratio = 1;
37
38 private $whichone;
39
40 private $zip_basename = '';
41
42 private $zipfiles_lastwritetime;
43
44 // 0 = unknown; false = failed
45 public $binzip = 0;
46
47 private $dbhandle;
48
49 private $dbhandle_isgz;
50
51 private $whichdb;
52
53 private $whichdb_suffix;
54
55 // Array of entities => times
56 private $altered_since = -1;
57
58 // Time for the current entity
59 private $makezip_if_altered_since = -1;
60
61 private $excluded_extensions = false;
62
63 private $excluded_wildcards = false;
64
65 private $excluded_prefixes = false;
66
67 private $use_zip_object = 'UpdraftPlus_ZipArchive';
68
69 public $debug = false;
70
71 public $updraft_dir;
72
73 private $site_name;
74
75 private $wpdb_obj;
76
77 private $job_file_entities = array();
78
79 private $first_run = 0;
80
81 // Record of zip files created
82 private $backup_files_array = array();
83
84 // Used when deciding to use the 'store' or 'deflate' zip storage method
85 private $extensions_to_not_compress = array();
86
87 // Append to this any skipped tables
88 private $skipped_tables;
89
90 // When initialised, a boolean
91 public $last_storage_instance;
92
93 // The absolute upper limit that will be considered for a zip batch (in bytes)
94 private $zip_batch_ceiling;
95
96 private $backup_excluded_patterns = array();
97
98 // Bytes of uncompressed data written since last open
99 private $db_current_raw_bytes = 0;
100
101 private $table_prefix;
102
103 private $table_prefix_raw;
104
105 private $many_rows_warning = false;
106
107 private $expected_rows = false;
108
109 // @var Boolean
110 private $try_split = false;
111
112 private $zip_microtime_start;
113
114 public $current_service;
115
116 private $existing_files;
117
118 private $existing_files_rawsize;
119
120 private $existing_zipfiles_size;
121
122 private $dbinfo;
123
124 private $duplicate_tables_exist = false;
125
126 private $first_linked_index;
127
128 private $current_pruning_instance_id = '';
129
130 private $hash_list_of_complete_pruned_files = array();
131
132 private $pruning_number_of_files = 10;
133
134 private $minimum_time_to_cache_pruned_files = 25; // seconds
135
136 /**
137 * Current processing remote storage instance id
138 *
139 * @var String
140 */
141 public $current_instance;
142
143 /**
144 * Backup entity name for which a zip backup file is currently making
145 *
146 * @var String
147 */
148 public $make_zipfile_source;
149
150 // private $source;
151
152 /**
153 * Class constructor
154 *
155 * @param Array|String $backup_files - files to backup, or (string)'no'
156 * @param Integer $altered_since - only backup files altered since this time (UNIX epoch time)
157 */
158 public function __construct($backup_files, $altered_since = -1) {
159
160 global $updraftplus;
161
162 $this->site_name = $this->get_site_name();
163
164 $this->debug = UpdraftPlus_Options::get_updraft_option('updraft_debug_mode');
165 $this->updraft_dir = $updraftplus->backups_dir_location();
166
167 updraft_try_include_file('includes/class-database-utility.php', 'require_once');
168
169 if ('no' === $backup_files) {
170 $this->use_zip_object = 'UpdraftPlus_PclZip';
171 return;
172 }
173
174 $this->extensions_to_not_compress = array_unique(array_map('strtolower', array_map('trim', explode(',', UPDRAFTPLUS_ZIP_NOCOMPRESS))));
175
176 $this->backup_excluded_patterns = array(
177 array(
178 // all in one wp migration pattern: WP_PLUGIN_DIR/all-in-one-wp-migration/storage/*/*.wpress, `ai1wm-backups` folder in wp-content is already implicitly handled on the UDP settings with a `*backups` predefined exclusion rule for `others` directory
179 'directory' => realpath(WP_PLUGIN_DIR).DIRECTORY_SEPARATOR.'all-in-one-wp-migration'.DIRECTORY_SEPARATOR.'storage',
180 'regex' => '/.+\.wpress$/is',
181 ),
182 );
183
184 $this->altered_since = $altered_since;
185
186 $resumptions_since_last_successful = $updraftplus->current_resumption - $updraftplus->last_successful_resumption;
187
188 // false means 'tried + failed'; whereas 0 means 'not yet tried'
189 // Disallow binzip on OpenVZ when we're not sure there's plenty of memory
190 if (0 === $this->binzip && (!defined('UPDRAFTPLUS_PREFERPCLZIP') || !UPDRAFTPLUS_PREFERPCLZIP) && (!defined('UPDRAFTPLUS_NO_BINZIP') || !UPDRAFTPLUS_NO_BINZIP) && ($updraftplus->current_resumption < 9 || $resumptions_since_last_successful < 2)) {
191
192 if (@file_exists('/proc/user_beancounters') && @file_exists('/proc/meminfo') && @is_readable('/proc/meminfo')) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
193 $meminfo = @file_get_contents('/proc/meminfo', false, null, 0, 200);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
194 if (is_string($meminfo) && preg_match('/MemTotal:\s+(\d+) kB/', $meminfo, $matches)) {
195 $memory_mb = $matches[1]/1024;
196 // If the report is of a large amount, then we're probably getting the total memory on the hypervisor (this has been observed), and don't really know the VPS's memory
197 $vz_log = "OpenVZ; reported memory: ".round($memory_mb, 1)." MB";
198 if ($memory_mb < 1024 || $memory_mb > 8192) {
199 $openvz_lowmem = true;
200 $vz_log .= " (will not use BinZip)";
201 }
202 $updraftplus->log($vz_log);
203 }
204 }
205 if (empty($openvz_lowmem)) {
206 $updraftplus->log('Checking if we have a zip executable available');
207 $binzip = $updraftplus->find_working_bin_zip();
208 if (is_string($binzip)) {
209 $updraftplus->log("Zip engine: found/will use a binary zip: $binzip");
210 $this->binzip = $binzip;
211 $this->use_zip_object = 'UpdraftPlus_BinZip';
212 }
213 }
214 }
215
216 // In tests, PclZip was found to be 25% slower than ZipArchive
217 if ('UpdraftPlus_PclZip' != $this->use_zip_object && empty($this->binzip) && ((defined('UPDRAFTPLUS_PREFERPCLZIP') && UPDRAFTPLUS_PREFERPCLZIP == true) || !class_exists('ZipArchive') || !class_exists('UpdraftPlus_ZipArchive') || (!extension_loaded('zip') && !method_exists('ZipArchive', 'AddFile')))) {
218 global $updraftplus;
219 $updraftplus->log("Zip engine: ZipArchive (a.k.a. php-zip) is not available or is disabled (will use PclZip (much slower) if needed)");
220 $this->use_zip_object = 'UpdraftPlus_PclZip';
221 }
222
223 $this->zip_batch_ceiling = (defined('UPDRAFTPLUS_ZIP_BATCH_CEILING') && UPDRAFTPLUS_ZIP_BATCH_CEILING > 104857600) ? UPDRAFTPLUS_ZIP_BATCH_CEILING : 200 * 1048576;
224
225 if (defined('UPDRAFTPLUS_PRUNING_NUMBER_OF_FILES') && (int) UPDRAFTPLUS_PRUNING_NUMBER_OF_FILES > $this->pruning_number_of_files) $this->pruning_number_of_files = UPDRAFTPLUS_PRUNING_NUMBER_OF_FILES;
226 if (defined('UPDRAFTPLUS_MIN_TIME_TO_CACHE_PRUNED_FILES') && (int) UPDRAFTPLUS_MIN_TIME_TO_CACHE_PRUNED_FILES > $this->minimum_time_to_cache_pruned_files) $this->minimum_time_to_cache_pruned_files = UPDRAFTPLUS_MIN_TIME_TO_CACHE_PRUNED_FILES;
227
228 add_filter('updraftplus_exclude_file', array($this, 'backup_exclude_file'), 10, 2);
229
230 }
231
232 /**
233 * Get a site name suitable for use in the backup filename
234 *
235 * @return String
236 */
237 private function get_site_name() {
238 // Get the blog name and rip out known-problematic characters. Remember that we may need to be able to upload this to any FTP server or cloud storage, where filename support may be unknown
239 $site_name = str_replace('__', '_', preg_replace('/[^A-Za-z0-9_]/', '', str_replace(' ', '_', substr(get_bloginfo(), 0, 32))));
240 if (!$site_name || preg_match('#^_+$#', $site_name)) {
241 // Try again...
242 $parsed_url = parse_url(home_url(), PHP_URL_HOST);
243 $parsed_subdir = untrailingslashit(parse_url(home_url(), PHP_URL_PATH));
244 if ($parsed_subdir && '/' != $parsed_subdir) $parsed_url .= str_replace(array('/', '\\'), '_', $parsed_subdir);
245 $site_name = str_replace('__', '_', preg_replace('/[^A-Za-z0-9_]/', '', str_replace(' ', '_', substr($parsed_url, 0, 32))));
246 if (!$site_name || preg_match('#^_+$#', $site_name)) $site_name = 'WordPress_Backup';
247 }
248
249 // Allow an over-ride. Careful about introducing characters not supported by your filesystem or cloud storage.
250 return apply_filters('updraftplus_blog_name', $site_name);
251 }
252
253 /**
254 * Public, because called from the 'More Files' add-on
255 *
256 * @param String|Array $create_from_dir Directory/ies to create the zip
257 * @param String $whichone Entity being backed up (e.g. 'plugins', 'uploads')
258 * @param String $backup_file_basename Name of backup file
259 * @param Integer $index Index of zip in the sequence
260 * @param Integer|Boolean $first_linked_index First linked index in the sequence, or false
261 *
262 * @return Boolean|Array - list of files, or false for failure
263 */
264 public function create_zip($create_from_dir, $whichone, $backup_file_basename, $index, $first_linked_index = false) {
265 // Note: $create_from_dir can be an array or a string
266
267 if (function_exists('set_time_limit')) set_time_limit(UPDRAFTPLUS_SET_TIME_LIMIT);
268
269 $original_index = $index;
270
271 $this->index = $index;
272 $this->first_linked_index = (false === $first_linked_index) ? 0 : $first_linked_index;
273 $this->whichone = $whichone;
274
275 global $updraftplus;
276
277 $this->zip_split_every = max((int) $updraftplus->jobdata_get('split_every'), UPDRAFTPLUS_SPLIT_MIN)*1048576;
278
279 if ('others' != $whichone) $updraftplus->log("Beginning creation of dump of $whichone (split every: ".round($this->zip_split_every/1048576, 1)." MB)");
280
281 if (is_string($create_from_dir) && !file_exists($create_from_dir)) {
282 $flag_error = true;
283 $updraftplus->log("Does not exist: $create_from_dir");
284 if ('mu-plugins' == $whichone) {
285 if (!function_exists('get_mu_plugins')) include_once(ABSPATH.'wp-admin/includes/plugin.php');
286 $mu_plugins = get_mu_plugins();
287 if (count($mu_plugins) == 0) {
288 $updraftplus->log("There are no mu-plugins to backup. Will not raise an error.");
289 $flag_error = false;
290 }
291 }
292 /* translators: 1: Entity name, 2: Directory path */
293 if ($flag_error) $updraftplus->log(sprintf(__('%1$s - could not back this entity up; the corresponding directory does not exist (%2$s)', 'updraftplus'), $whichone, $create_from_dir), 'error');
294 return false;
295 }
296
297 $itext = empty($index) ? '' : $index+1;
298 $base_path = $backup_file_basename.'-'.$whichone.$itext.'.zip';
299 $full_path = $this->updraft_dir.'/'.$base_path;
300 $time_now = time();
301
302 // This is compatible with filenames which indicate increments, as it is looking only for the current increment
303 if (file_exists($full_path) || $updraftplus->is_uploaded($base_path)) {
304 // Gather any further files that may also exist
305 $files_existing = array();
306 while (file_exists($full_path) || $updraftplus->is_uploaded($base_path)) {
307 $files_existing[] = $base_path;
308 if (file_exists($full_path)) {
309 $time_mod = (int) @filemtime($full_path);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
310 $updraftplus->log($base_path.": this file has already been created (age: ".round($time_now-$time_mod, 1)." s)");
311 if ($time_mod > 100 && ($time_now - $time_mod) < 30) {
312 UpdraftPlus_Job_Scheduler::terminate_due_to_activity($base_path, $time_now, $time_mod);
313 }
314 }
315 $index++;
316 // This is compatible with filenames which indicate increments, as it is looking only for the current increment
317 $base_path = $backup_file_basename.'-'.$whichone.($index+1).'.zip';
318 $full_path = $this->updraft_dir.'/'.$base_path;
319 }
320 }
321
322 // Temporary file, to be able to detect actual completion (upon which, it is renamed)
323
324 // Jun-13 - be more aggressive in removing temporary files from earlier attempts - anything >=600 seconds old of this kind
325 UpdraftPlus_Filesystem_Functions::clean_temporary_files('_'.$updraftplus->file_nonce."-$whichone", 600);
326
327 // Firstly, make sure that the temporary file is not already being written to - which can happen if a resumption takes place whilst an old run is still active
328 $zip_name = $full_path.'.tmp';
329 $time_mod = file_exists($zip_name) ? filemtime($zip_name) : 0;
330 if (file_exists($zip_name) && $time_mod>100 && ($time_now-$time_mod)<30) {
331 UpdraftPlus_Job_Scheduler::terminate_due_to_activity($zip_name, $time_now, $time_mod);
332 }
333
334 if (file_exists($zip_name)) {
335 $updraftplus->log("File exists ($zip_name), but was not modified within the last 30 seconds, so we assume that any previous run has now terminated (time_mod=$time_mod, time_now=$time_now, diff=".($time_now-$time_mod).")");
336 }
337
338 // Now, check for other forms of temporary file, which would indicate that some activity is going on (even if it hasn't made it into the main zip file yet)
339 // Note: this doesn't catch PclZip temporary files
340 $d = dir($this->updraft_dir);
341 $match = '_'.$updraftplus->file_nonce."-".$whichone;
342 while (false !== ($e = $d->read())) {
343 if ('.' == $e || '..' == $e || !is_file($this->updraft_dir.'/'.$e)) continue;
344 $ziparchive_match = preg_match("/$match(?:[0-9]*)\.zip\.tmp\.[A-Za-z0-9]+$/i", $e);
345 $binzip_match = preg_match("/^zi([A-Za-z0-9]){6}$/", $e);
346 $pclzip_match = preg_match("/^pclzip-[a-z0-9]+.(?:gz|tmp)$/", $e);
347 if ($time_now-filemtime($this->updraft_dir.'/'.$e) < 30 && ($ziparchive_match || (0 != $updraftplus->current_resumption && ($binzip_match || $pclzip_match)))) {
348 UpdraftPlus_Job_Scheduler::terminate_due_to_activity($this->updraft_dir.'/'.$e, $time_now, filemtime($this->updraft_dir.'/'.$e));
349 }
350 }
351 @$d->close();// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the method.
352 clearstatcache();
353
354 if (isset($files_existing)) {
355 // Because of zip-splitting, the mere fact that files exist is not enough to indicate that the entity is finished. For that, we need to also see that no subsequent file has been started.
356 // Q. What if the previous runner died in between zips, and it is our job to start the next one? A. The next temporary file is created before finishing the former zip, so we are safe (and we are also safe-guarded by the updated value of the index being stored in the database).
357 return $files_existing;
358 }
359
360 $this->log_account_space();
361
362 $this->zip_microtime_start = microtime(true);
363
364 // The paths in the zip should then begin with '$whichone', having removed WP_CONTENT_DIR from the front
365 $zipcode = $this->make_zipfile($create_from_dir, $backup_file_basename, $whichone);
366 if (true !== $zipcode) {
367 $updraftplus->log("ERROR: Zip failure: Could not create $whichone zip (".$this->index." / $index)");
368 $updraftplus->log(
369 sprintf(
370 /* translators: %s: Entity name */
371 __('Could not create %s zip.', 'updraftplus'),
372 $whichone).' '.
373 __('Consult the log file for more information.', 'updraftplus'),
374 'error');
375 // The caller is required to update $index from $this->index
376 return false;
377 } else {
378 $itext = empty($this->index) ? '' : $this->index+1;
379 $full_path = $this->updraft_dir.'/'.$backup_file_basename.'-'.$whichone.$itext.'.zip';
380 if (file_exists($full_path.'.tmp')) {
381 if (@filesize($full_path.'.tmp') === 0) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
382 $updraftplus->log("Did not create $whichone zip (".$this->index.") - not needed");
383 @unlink($full_path.'.tmp');// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
384 } else {
385
386 $checksum_description = '';
387
388 $checksums = $updraftplus->which_checksums();
389
390 foreach ($checksums as $checksum) {
391
392 $cksum = hash_file($checksum, $full_path.'.tmp');
393 $updraftplus->jobdata_set($checksum.'-'.$whichone.$this->index, $cksum);
394 if ($checksum_description) $checksum_description .= ', ';
395 $checksum_description .= "$checksum: $cksum";
396
397 }
398
399 @rename($full_path.'.tmp', $full_path);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
400 $timetaken = max(microtime(true)-$this->zip_microtime_start, 0.000001);
401 $kbsize = filesize($full_path)/1024;
402 $updraftplus->jobdata_set('filesize-'.$whichone.$this->index, filesize($full_path));
403 $rate = round($kbsize/$timetaken, 1);
404 $updraftplus->log("Created $whichone zip (".$this->index.") - ".round($kbsize, 1)." KB in ".round($timetaken, 1)." s ($rate KB/s) ($checksum_description)");
405 // We can now remove any left-over temporary files from this job
406 }
407 } elseif ($this->index > $original_index) {
408 $updraftplus->log("Did not create $whichone zip (".$this->index.") - not needed (2)");
409 // Added 12-Feb-2014 (to help multiple morefiles)
410 $this->index--;
411 } else {
412 $updraftplus->log("Looked-for $whichone zip (".$this->index.") was not found (".basename($full_path).".tmp)", 'warning');
413 }
414 UpdraftPlus_Filesystem_Functions::clean_temporary_files('_'.$updraftplus->file_nonce."-$whichone", 0);
415 $this->maybe_cloud_backup(basename($full_path), $whichone, $this->index);
416 }
417
418 // Remove cache list files as well, if there are any
419 UpdraftPlus_Filesystem_Functions::clean_temporary_files('_'.$updraftplus->file_nonce."-$whichone", 0, true);
420
421 // Create the results array to send back (just the new ones, not any prior ones)
422 $files_existing = array();
423 $res_index = $original_index;
424 for ($i = $original_index; $i<= $this->index; $i++) {
425 $itext = empty($i) ? '' : ($i+1);
426 $full_path = $this->updraft_dir.'/'.$backup_file_basename.'-'.$whichone.$itext.'.zip';
427 if (file_exists($full_path) || $updraftplus->is_uploaded($backup_file_basename.'-'.$whichone.$itext.'.zip')) {
428 $files_existing[$res_index] = $backup_file_basename.'-'.$whichone.$itext.'.zip';
429 }
430 $res_index++;
431 }
432 return $files_existing;
433 }
434
435 /**
436 * This method is for calling outside of a cloud_backup() context. It constructs a list of services for which prune operations should be attempted, and then calls prune_retained_backups() if necessary upon them.
437 */
438 public function do_prune_standalone() {
439 global $updraftplus;
440
441 $services = (array) $updraftplus->just_one($updraftplus->jobdata_get('service'));
442
443 $prune_services = array();
444
445 foreach ($services as $service) {
446 if ('none' === $service || '' == $service) continue;
447
448 $objname = "UpdraftPlus_BackupModule_{$service}";
449 if (!class_exists($objname) && file_exists(UPDRAFTPLUS_DIR.'/methods/'.$service.'.php')) {
450 updraft_try_include_file('methods/'.$service.'.php', 'include_once');
451 }
452 if (class_exists($objname)) {
453 $remote_obj = new $objname;
454 $prune_services[$service]['all'] = array($remote_obj, null);
455 } else {
456 $updraftplus->log("Could not prune from service $service: remote method not found");
457 }
458
459 }
460
461 if (!empty($prune_services)) $this->prune_retained_backups($prune_services);
462 }
463
464 /**
465 * This function will check if backup archives exist and have a usable manifest if so it will attempt to send them for upload
466 *
467 * @param String $file - the name of the zip file
468 * @param String $whichone - the entity type
469 * @param Integer $index - the entity index
470 *
471 * @return void
472 */
473 private function maybe_cloud_backup($file, $whichone, $index) {
474
475 global $updraftplus;
476
477 // Check if the feature is enabled
478 if (!defined('UPDRAFTPLUS_UPLOAD_AFTER_CREATE') || UPDRAFTPLUS_UPLOAD_AFTER_CREATE) {
479
480 if ($updraftplus->is_uploaded($file) || !is_file($this->updraft_dir.'/'.$file)) return;
481
482 $updraftplus->jobdata_set('filesize-'.$whichone.$index, filesize($this->updraft_dir.'/'.$file));
483
484 $backupable_entities = $updraftplus->get_backupable_file_entities(true);
485 $undone_files = array();
486
487 if (!isset($backupable_entities[$whichone]) && ('db' != substr($whichone, 0, 2))) return;
488
489 $manifest = $this->updraft_dir.'/'.$file.'.list.tmp';
490
491 if (!file_exists($manifest)) return;
492
493 $manifest_contents = json_decode(file_get_contents($manifest), true);
494
495 if (empty($manifest_contents) || empty($manifest_contents['files'])) return;
496
497 $undone_files[$whichone.$index] = $file;
498
499 $this->cloud_backup($undone_files, 'partialclouduploading');
500
501 // reset the jobstatus as we may have just uploaded a backup set
502 $updraftplus->jobdata_set('jobstatus', 'filescreating');
503 }
504 }
505
506 /**
507 * Dispatch to the relevant function
508 *
509 * @param Array $backup_array - List of archives for the backup
510 * @param String $stage - The stage we are uploading at (clouduploading, partialclouduploading)
511 */
512 public function cloud_backup($backup_array, $stage = 'clouduploading') {
513
514 global $updraftplus;
515
516 $services = (array) $updraftplus->just_one($updraftplus->jobdata_get('service'));
517 $remote_storage_instances = $updraftplus->jobdata_get('remote_storage_instances', array());
518
519 // We need to make sure that the loop below actually runs
520 if (empty($services)) $services = array('none');
521
522 $storage_objects_and_ids = UpdraftPlus_Storage_Methods_Interface::get_enabled_storage_objects_and_ids($services, $remote_storage_instances);
523
524 $total_instances_count = 0;
525
526 foreach ($storage_objects_and_ids as $service) {
527 if ($service['object']->supports_feature('multi_options')) $total_instances_count += count($service['instance_settings']);
528 }
529
530 $updraftplus->jobdata_set('jobstatus', $stage);
531
532 $updraftplus->register_wp_http_option_hooks();
533
534 $upload_status = $updraftplus->jobdata_get('uploading_substatus');
535 if (!is_array($upload_status) || !isset($upload_status['t'])) {
536 $upload_status = array('i' => 0, 'p' => 0, 't' => max(1, $total_instances_count)*count($backup_array));
537 $updraftplus->jobdata_set('uploading_substatus', $upload_status);
538 } elseif (is_array($upload_status) && isset($upload_status['t'])) {
539 $upload_status['t'] = $upload_status['i'] + max(1, $total_instances_count)*count($backup_array);
540 $updraftplus->jobdata_set('uploading_substatus', $upload_status);
541 }
542
543 $do_prune = array();
544
545 // If there was no check-in last time, then attempt a different service first - in case a time-out on the attempted service leads to no activity and everything stopping
546 if (count($services) >1 && $updraftplus->no_checkin_last_time) {
547 $updraftplus->log('No check-in last time: will try a different remote service first');
548 array_push($services, array_shift($services));
549 // Make sure that the 'no worthwhile activity' detector isn't flumoxed by the starting of a new upload at 0%
550 if ($updraftplus->current_resumption > 9) $updraftplus->jobdata_set('uploaded_lastreset', $updraftplus->current_resumption);
551 if (1 == ($updraftplus->current_resumption % 2) && count($services)>2) array_push($services, array_shift($services));
552 }
553
554 $errors_before_uploads = $updraftplus->error_count();
555
556 foreach ($services as $ind => $service) {
557 try {
558 $instance_id_count = 0;
559 $total_instance_ids = ('none' !== $service && '' !== $service && $storage_objects_and_ids[$service]['object']->supports_feature('multi_options')) ? count($storage_objects_and_ids[$service]['instance_settings']) : 1;
560
561 // Used for logging by record_upload_chunk()
562 $this->current_service = $service;
563
564 // Used when deciding whether to delete the local file
565 $this->last_storage_instance = ($ind+1 >= count($services) && $instance_id_count+1 >= $total_instance_ids && $errors_before_uploads == $updraftplus->error_count()) ? true : false;
566 $log_extra = $this->last_storage_instance ? ' (last)' : '';
567 $updraftplus->log("Cloud backup selection (".($ind+1)."/".count($services)."): ".$service." with instance (".($instance_id_count+1)."/".$total_instance_ids.")".$log_extra);
568 if (function_exists('set_time_limit')) @set_time_limit(UPDRAFTPLUS_SET_TIME_LIMIT);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
569
570 if ('none' == $service || '' == $service) {
571 $updraftplus->log('No remote despatch: user chose no remote backup service');
572 // Still want to mark as "uploaded", to signal that nothing more needs doing. (Important on incremental runs with no cloud storage).
573 foreach ($backup_array as $file) {
574 if ($updraftplus->is_uploaded($file)) {
575 $updraftplus->log("Already uploaded: $file");
576 } else {
577 $updraftplus->uploaded_file($file, true);
578 }
579 $fullpath = $this->updraft_dir.'/'.$file;
580 if (file_exists($fullpath.'.list.tmp')) {
581 $updraftplus->log("Deleting zip manifest ({$file}.list.tmp)");
582 unlink($fullpath.'.list.tmp');
583 }
584 }
585 $this->prune_retained_backups(array('none' => array('all' => array(null, null))));
586 } elseif (!empty($storage_objects_and_ids[$service]['object']) && !$storage_objects_and_ids[$service]['object']->supports_feature('multi_options')) {
587 $remote_obj = $storage_objects_and_ids[$service]['object'];
588
589 $do_prune = array_merge_recursive($do_prune, $this->upload_cloud($remote_obj, $service, $backup_array, ''));
590 } elseif (!empty($storage_objects_and_ids[$service]['instance_settings'])) {
591 foreach ($storage_objects_and_ids[$service]['instance_settings'] as $instance_id => $options) {
592
593 if ($instance_id_count > 0) {
594 $this->last_storage_instance = ($ind+1 >= count($services) && $instance_id_count+1 >= $total_instance_ids && $errors_before_uploads == $updraftplus->error_count()) ? true : false;
595 $log_extra = $this->last_storage_instance ? ' (last)' : '';
596 $updraftplus->log("Cloud backup selection (".($ind+1)."/".count($services)."): ".$service." with instance (".($instance_id_count+1)."/".$total_instance_ids.")".$log_extra);
597 }
598
599 // Used for logging by record_upload_chunk()
600 $this->current_instance = $instance_id;
601
602 if (!isset($options['instance_enabled'])) $options['instance_enabled'] = 1;
603
604 // if $remote_storage_instances is not empty then we are looping over a list of instances the user wants to backup to so we want to ignore if the instance is enabled or not
605 if (1 == $options['instance_enabled'] || !empty($remote_storage_instances)) {
606 $remote_obj = $storage_objects_and_ids[$service]['object'];
607 $remote_obj->set_options($options, true, $instance_id);
608 $do_prune = array_merge_recursive($do_prune, $this->upload_cloud($remote_obj, $service, $backup_array, $instance_id));
609 } else {
610 $updraftplus->log("This instance id ($instance_id) is set as inactive.");
611 }
612
613 $instance_id_count++;
614 }
615 }
616 } catch (Exception $e) {
617 $log_message = 'Exception ('.get_class($e).') occurred during backup uploads to the '.$service.'. Exception Message: '.$e->getMessage().' (Code: '.$e->getCode().', line '.$e->getLine().' in '.$e->getFile().')';
618 $updraftplus->log($log_message);
619 UpdraftPlus_Manipulation_Functions::error_log($log_message);
620 /* translators: 1: Exception class, 2: Exception message */
621 $updraftplus->log(sprintf(__('A PHP exception (%1$s) has occurred: %2$s', 'updraftplus'), get_class($e), $e->getMessage()), 'error');
622 } catch (Error $e) {// phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound -- This Error class will only get triggered during runtime but we don't explicitly throw this class in our code; so we only catch it when PHP throws it.
623 $log_message = 'PHP Fatal error ('.get_class($e).') has occurred during backup uploads to the '.$service.'. Error Message: '.$e->getMessage().' (Code: '.$e->getCode().', line '.$e->getLine().' in '.$e->getFile().')';
624 $updraftplus->log($log_message);
625 UpdraftPlus_Manipulation_Functions::error_log($log_message);
626 /* translators: 1: Exception class, 2: Exception message */
627 $updraftplus->log(sprintf(__('A PHP fatal error (%1$s) has occurred: %2$s', 'updraftplus'), get_class($e), $e->getMessage()), 'error');
628 }
629 }
630
631 if (!empty($do_prune) && 'clouduploading' == $stage) $this->prune_retained_backups($do_prune);
632
633 $updraftplus->register_wp_http_option_hooks(false);
634
635 }
636
637 /**
638 * This method will start the upload of the backups to the chosen remote storage method and return an array of files to be pruned and their location.
639 *
640 * @param Object $remote_obj - the remote storage object
641 * @param String $service - the name of the service we are uploading to
642 * @param Array $backup_array - an array that contains the backup files we want to upload
643 * @param String $instance_id - the instance id we are using
644 * @return Array - an array with information about what files to prune and where they are located
645 */
646 private function upload_cloud($remote_obj, $service, $backup_array, $instance_id) {
647
648 global $updraftplus;
649
650 $do_prune = array();
651
652 if ('' == $instance_id) {
653 $updraftplus->log("Beginning dispatch of backup to remote ($service)");
654 } else {
655 $updraftplus->log("Beginning dispatch of backup to remote ($service) (instance identifier $instance_id)");
656 }
657
658 $errors_before_uploads = $updraftplus->error_count();
659
660 $sarray = array();
661 foreach ($backup_array as $bind => $file) {
662 if ($updraftplus->is_uploaded($file, $service, $instance_id)) {
663 if ('' == $instance_id) {
664 $updraftplus->log("Already uploaded to $service: $file", 'notice', false, true);
665 } else {
666 $updraftplus->log("Already uploaded to $service / $instance_id: $file", 'notice', false, true);
667 }
668 // If this is the last instance and this file has already been uploaded then we need to mark it as complete in order to get the local file cleaned up.
669 if (!empty($this->last_storage_instance)) $updraftplus->uploaded_file($file);
670 } else {
671 $sarray[$bind] = $file;
672 }
673 }
674
675 if (count($sarray) > 0) {
676 $pass_to_prune = $remote_obj->backup($sarray);
677 if ('remotesend' != $service) {
678 $do_prune[$service][$instance_id] = array($remote_obj, $pass_to_prune);
679 } else {
680 $do_prune[$service]['default'] = array($remote_obj, $pass_to_prune);
681 }
682
683 // Check there are no errors in the uploads, if none then call upload_completed() if it exists otherwise mark as complete
684 if ($errors_before_uploads == $updraftplus->error_count()) {
685 if (is_callable(array($remote_obj, 'upload_completed'))) {
686 $result = $remote_obj->upload_completed();
687 if ($result) $updraftplus->mark_upload_complete($service);
688 } else {
689 $updraftplus->mark_upload_complete($service);
690 }
691 }
692 } else {
693 // We still need to make sure that prune is run on this remote storage method, even if all entities were previously uploaded
694 $do_prune[$service]['all'] = array($remote_obj, null);
695 }
696
697 return $do_prune;
698 }
699
700 /**
701 * Group the backup history into sets for retention processing and indicate the retention rule to apply to each group. This is a 'default' function which just puts them all in together.
702 *
703 * @param Array $backup_history
704 *
705 * @return Array
706 */
707 private function group_backups($backup_history) {
708 return array(array('sets' => $backup_history, 'process_order' => 'keep_newest'));
709 }
710
711 /**
712 * Logs a message; with the message being logged to the database also only if that has not been done in the last 3 seconds. Useful for better overall performance on slow database servers with rapid logging.
713 *
714 * @uses UpdraftPlus::log()
715 *
716 * @param String $message - the message to log
717 * @param String $level - the log level
718 */
719 private function log_with_db_occasionally($message, $level = 'notice') {
720 global $updraftplus;
721 static $last_db = false;
722
723 if (time() > $last_db + 3) {
724 $last_db = time();
725 $skip_dblog = false;
726 } else {
727 $skip_dblog = true;
728 }
729
730 return $updraftplus->log($message, $level, false, $skip_dblog);
731 }
732
733 /**
734 * Prunes historical backups, according to the user's settings
735 *
736 * @param Array $services - An associative array with list of services as key and remote object and boolean flag as values to prune on. This must be an array (i.e. it is not flexible like some other places)
737 *
738 * @return void
739 */
740 public function prune_retained_backups($services) {
741
742 global $updraftplus, $wpdb;
743
744 if ('' != $updraftplus->jobdata_get('remotesend_info')) {
745 $updraftplus->log("Prune old backups from local store: skipping, as this was a remote send operation");
746 return;
747 }
748
749 if (method_exists($wpdb, 'check_connection') && (!defined('UPDRAFTPLUS_SUPPRESS_CONNECTION_CHECKS') || !UPDRAFTPLUS_SUPPRESS_CONNECTION_CHECKS)) {
750 if (!$wpdb->check_connection(false)) {
751 UpdraftPlus_Job_Scheduler::reschedule(60);
752 $updraftplus->log('It seems the database went away; scheduling a resumption and terminating for now');
753 UpdraftPlus_Job_Scheduler::record_still_alive();
754 die;
755 }
756 }
757
758 // If they turned off deletion on local backups, then there is nothing to do
759 if (!UpdraftPlus_Options::get_updraft_option('updraft_delete_local', 1) && 1 == count($services) && array_key_exists('none', $services)) {
760 $updraftplus->log("Prune old backups from local store: nothing to do, since the user disabled local deletion and we are using local backups");
761 return;
762 }
763
764 $this->hash_list_of_complete_pruned_files = $updraftplus->jobdata_get('hash_list_of_complete_pruned_files');
765 if (!is_array($this->hash_list_of_complete_pruned_files)) $this->hash_list_of_complete_pruned_files = array();
766
767 $updraftplus->jobdata_set_multi(array('jobstatus' => 'pruning', 'prune' => 'begun'));
768
769 // Number of backups to retain - files
770 $updraft_retain = UpdraftPlus_Options::get_updraft_option('updraft_retain', 2);
771 $updraft_retain = is_numeric($updraft_retain) ? $updraft_retain : 1;
772
773 // Number of backups to retain - db
774 $updraft_retain_db = UpdraftPlus_Options::get_updraft_option('updraft_retain_db', $updraft_retain);
775 $updraft_retain_db = is_numeric($updraft_retain_db) ? $updraft_retain_db : 1;
776
777 $updraftplus->log("Retain: beginning examination of existing backup sets; user setting: retain_files=$updraft_retain, retain_db=$updraft_retain_db");
778
779 // Returns an array, most recent first, of backup sets
780 $backup_history = UpdraftPlus_Backup_History::get_history();
781
782 $ignored_because_imported = array();
783
784 // Remove non-native (imported) backups, which are neither counted nor pruned. It's neater to do these in advance, and log only one line.
785 $functional_backup_history = $backup_history;
786 foreach ($functional_backup_history as $backup_time => $backup_to_examine) {
787 if (isset($backup_to_examine['native']) && false == $backup_to_examine['native']) {
788 $ignored_because_imported[] = $backup_time;
789 unset($functional_backup_history[$backup_time]);
790 }
791 }
792 if (!empty($ignored_because_imported)) {
793 $updraftplus->log("These backup set(s) were imported from a remote location, so will not be counted or pruned. Skipping: ".implode(', ', $ignored_because_imported));
794 }
795
796 $backupable_entities = $updraftplus->get_backupable_file_entities(true);
797
798 $database_backups_found = array();
799
800 $file_entities_backups_found = array();
801 foreach ($backupable_entities as $entity => $info) {
802 $file_entities_backups_found[$entity] = 0;
803 }
804
805 if (false === ($backup_db_groups = apply_filters('updraftplus_group_backups_for_pruning', false, $functional_backup_history, 'db'))) {
806 $backup_db_groups = $this->group_backups($functional_backup_history);
807 }
808 $updraftplus->log("Number of backup sets in history: ".count($backup_history)."; groups (db): ".count($backup_db_groups));
809
810 foreach ($backup_db_groups as $group_id => $group) {
811
812 // N.B. The array returned by UpdraftPlus_Backup_History::get_history() is already sorted, with most-recent first
813
814 if (empty($group['sets']) || !is_array($group['sets'])) continue;
815 $sets = $group['sets'];
816
817 // Sort the groups into the desired "keep this first" order
818 $process_order = (!empty($group['process_order']) && 'keep_oldest' == $group['process_order']) ? 'keep_oldest' : 'keep_newest';
819 if ('keep_oldest' == $process_order) ksort($sets);
820
821 $rule = !empty($group['rule']) ? $group['rule'] : array('after-howmany' => 0, 'after-period' => 0, 'every-period' => 1, 'every-howmany' => 1);
822
823 foreach ($sets as $backup_datestamp => $backup_to_examine) {
824
825 $files_to_prune = array();
826 $nonce = empty($backup_to_examine['nonce']) ? '???' : $backup_to_examine['nonce'];
827
828 // $backup_to_examine is an array of file names, keyed on db/plugins/themes/uploads
829 // The new backup_history array is saved afterwards, so remember to unset the ones that are to be deleted
830 $this->log_with_db_occasionally(sprintf("Examining (for databases) backup set with group_id=$group_id, nonce=%s, datestamp=%s (%s)", $nonce, $backup_datestamp, gmdate('M d Y H:i:s', $backup_datestamp)));
831
832 // "Always Keep" Backups should be counted in the count of how many have been retained for purposes of the "how many to retain" count... but if that count is already matched, it's not a problem
833 $is_always_keep = !empty($backup_to_examine['always_keep']);
834
835 // Auto-backups are only counted or deleted once we have reached the retain limit - before that, they are skipped
836 $is_autobackup = !empty($backup_to_examine['autobackup']);
837
838 $remote_sent = (!empty($backup_to_examine['service']) && ((is_array($backup_to_examine['service']) && in_array('remotesend', $backup_to_examine['service'])) || 'remotesend' === $backup_to_examine['service'])) ? true : false;
839
840 $any_deleted_via_filter_yet = false;
841
842 // Databases
843 foreach ($backup_to_examine as $key => $data) {
844 if ('db' != strtolower(substr($key, 0, 2)) || '-size' == substr($key, -5, 5)) continue;
845
846 if (empty($database_backups_found[$key])) $database_backups_found[$key] = 0;
847
848 if ($nonce == $updraftplus->nonce || $nonce == $updraftplus->file_nonce) {
849 $this->log_with_db_occasionally("This backup set is the backup set just made, so will not be deleted.");
850 $database_backups_found[$key]++;
851 continue;
852 }
853
854 if ($is_always_keep) {
855 if ($database_backups_found[$key] < $updraft_retain) {
856 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an 'Always Keep' backup, and we have not yet reached any retain limits, so it should be counted in the count of how many have been retained for purposes of the 'how many to retain' count. It will not be pruned. Skipping.");
857 $database_backups_found[$key]++;
858 } else {
859 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an 'Always Keep' backup, so it will not be pruned. Skipping.");
860 }
861 continue;
862 }
863
864 if ($is_autobackup) {
865 if ($any_deleted_via_filter_yet) {
866 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an automatic backup, but we have previously deleted a backup due to a limit, so it will be pruned (but not counted towards numerical limits).");
867 $prune_it = true;
868 } elseif ($database_backups_found[$key] < $updraft_retain_db) {
869 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an automatic backup, and we have not yet reached any retain limits, so it will not be counted or pruned. Skipping.");
870 continue;
871 } else {
872 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an automatic backup, and we have already reached retain limits, so it will be pruned.");
873 $prune_it = true;
874 }
875 } else {
876 $prune_it = false;
877 }
878
879 if ($remote_sent) {
880 $prune_it = true;
881 $this->log_with_db_occasionally("$backup_datestamp: $key: was sent to remote site; will remove from local record (only)");
882 }
883
884 // All non-auto backups must be run through this filter (in date order) regardless of the current state of $prune_it - so that filters are able to track state.
885 $prune_it_before_filter = $prune_it;
886
887 if (!$is_autobackup) $prune_it = apply_filters('updraftplus_prune_or_not', $prune_it, 'db', $backup_datestamp, $key, $database_backups_found[$key], $rule, $group_id);
888
889 // Apply the final retention limit list (do not increase the 'retained' counter before seeing if the backup is being pruned for some other reason)
890 if (!$prune_it && !$is_autobackup) {
891
892 if ($database_backups_found[$key] + 1 > $updraft_retain_db) {
893 $prune_it = true;
894
895 $fname = is_string($data) ? $data : $data[0];
896 $this->log_with_db_occasionally("$backup_datestamp: $key: this set includes a database (".$fname."); db count is now ".$database_backups_found[$key]);
897
898 $this->log_with_db_occasionally("$backup_datestamp: $key: over retain limit ($updraft_retain_db); will delete this database");
899 }
900
901 }
902
903 if ($prune_it) {
904 if (!$prune_it_before_filter) $any_deleted_via_filter_yet = true;
905
906 if (!empty($data)) {
907 $size_key = $key.'-size';
908 $size = isset($backup_to_examine[$size_key]) ? $backup_to_examine[$size_key] : null;
909 foreach ($services as $service => $instance_ids_to_prune) {
910 foreach ($instance_ids_to_prune as $instance_id_to_prune => $sd) {
911 $this->current_pruning_instance_id = $instance_id_to_prune;
912 if ('none' != $service && '' != $service && $sd[0]->supports_feature('multi_options')) {
913 $storage_objects_and_ids = UpdraftPlus_Storage_Methods_Interface::get_storage_objects_and_ids(array($service));
914 if ('all' == $instance_id_to_prune) {
915 foreach ($storage_objects_and_ids[$service]['instance_settings'] as $saved_instance_id => $options) {
916 $this->current_pruning_instance_id = $saved_instance_id;
917 $sd[0]->set_options($options, false, $saved_instance_id);
918 $this->prune_file($service, $data, $sd[0], $sd[1], array($size));
919 }
920 } else {
921 $opts = $storage_objects_and_ids[$service]['instance_settings'][$instance_id_to_prune];
922 $sd[0]->set_options($opts, false, $instance_id_to_prune);
923 $this->prune_file($service, $data, $sd[0], $sd[1], array($size));
924 }
925 } else {
926 $this->current_pruning_instance_id = empty($service) ? 'none' : $service;
927 $this->prune_file($service, $data, $sd[0], $sd[1], array($size));
928 }
929 }
930 }
931 }
932 $this->current_pruning_instance_id = '';
933 unset($backup_to_examine[$key]);
934 UpdraftPlus_Job_Scheduler::record_still_alive();
935 } elseif (!$is_autobackup) {
936 $database_backups_found[$key]++;
937 }
938
939 $backup_to_examine = $this->remove_backup_set_if_empty($backup_to_examine, $backupable_entities);
940 if (empty($backup_to_examine)) {
941 unset($functional_backup_history[$backup_datestamp]);
942 unset($backup_history[$backup_datestamp]);
943 $this->maybe_save_backup_history_and_reschedule($backup_history);
944 } else {
945 $functional_backup_history[$backup_datestamp] = $backup_to_examine;
946 $backup_history[$backup_datestamp] = $backup_to_examine;
947 }
948 }
949 }
950 }
951
952 if (false === ($backup_files_groups = apply_filters('updraftplus_group_backups_for_pruning', false, $functional_backup_history, 'files'))) {
953 $backup_files_groups = $this->group_backups($functional_backup_history);
954 }
955
956 $updraftplus->log("Number of backup sets in history: ".count($backup_history)."; groups (files): ".count($backup_files_groups));
957
958 // Now again - this time for the files
959 foreach ($backup_files_groups as $group_id => $group) {
960
961 // N.B. The array returned by UpdraftPlus_Backup_History::get_history() is already sorted, with most-recent first
962
963 if (empty($group['sets']) || !is_array($group['sets'])) continue;
964 $sets = $group['sets'];
965
966 // Sort the groups into the desired "keep this first" order
967 $process_order = (!empty($group['process_order']) && 'keep_oldest' == $group['process_order']) ? 'keep_oldest' : 'keep_newest';
968 // Youngest - i.e. smallest epoch - first
969 if ('keep_oldest' == $process_order) ksort($sets);
970
971 $rule = !empty($group['rule']) ? $group['rule'] : array('after-howmany' => 0, 'after-period' => 0, 'every-period' => 1, 'every-howmany' => 1);
972
973 foreach ($sets as $backup_datestamp => $backup_to_examine) {
974
975 $files_to_prune = array();
976 $nonce = empty($backup_to_examine['nonce']) ? '???' : $backup_to_examine['nonce'];
977
978 // $backup_to_examine is an array of file names, keyed on db/plugins/themes/uploads
979 // The new backup_history array is saved afterwards, so remember to unset the ones that are to be deleted
980 $this->log_with_db_occasionally(sprintf("Examining (for files) backup set with nonce=%s, datestamp=%s (%s)", $nonce, $backup_datestamp, gmdate('M d Y H:i:s', $backup_datestamp)));
981
982 // "Always Keep" Backups should be counted in the count of how many have been retained for purposes of the "how many to retain" count... but if that count is already matched, it's not a problem
983 $is_always_keep = !empty($backup_to_examine['always_keep']);
984
985 // Auto-backups are only counted or deleted once we have reached the retain limit - before that, they are skipped
986 $is_autobackup = !empty($backup_to_examine['autobackup']);
987
988 $remote_sent = (!empty($backup_to_examine['service']) && ((is_array($backup_to_examine['service']) && in_array('remotesend', $backup_to_examine['service'])) || 'remotesend' === $backup_to_examine['service'])) ? true : false;
989
990 $any_deleted_via_filter_yet = false;
991
992 $file_sizes = array();
993
994 // Files
995 foreach ($backupable_entities as $entity => $info) {
996 if (!empty($backup_to_examine[$entity])) {
997
998 // This should only be able to happen if you import backups with a future timestamp
999 if ($nonce == $updraftplus->nonce || $nonce == $updraftplus->file_nonce) {
1000 $updraftplus->log("This backup set is the backup set just made, so will not be deleted.");
1001 $file_entities_backups_found[$entity]++;
1002 continue;
1003 }
1004
1005 if ($is_always_keep) {
1006 if ($file_entities_backups_found[$entity] < $updraft_retain) {
1007 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an 'Always Keep' backup, and we have not yet reached any retain limits, so it should be counted in the count of how many have been retained for purposes of the 'how many to retain' count. It will not be pruned. Skipping.");
1008 $file_entities_backups_found[$entity]++;
1009 } else {
1010 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an 'Always Keep' backup, so it will not be pruned. Skipping.");
1011 }
1012 continue;
1013 }
1014
1015 if ($is_autobackup) {
1016 if ($any_deleted_via_filter_yet) {
1017 $this->log_with_db_occasionally("This backup set was an automatic backup, but we have previously deleted a backup due to a limit, so it will be pruned (but not counted towards numerical limits).");
1018 $prune_it = true;
1019 } elseif ($file_entities_backups_found[$entity] < $updraft_retain) {
1020 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an automatic backup, and we have not yet reached any retain limits, so it will not be counted or pruned. Skipping.");
1021 continue;
1022 } else {
1023 $this->log_with_db_occasionally("This backup set ($backup_datestamp) was an automatic backup, and we have already reached retain limits, so it will be pruned.");
1024 $prune_it = true;
1025 }
1026 } else {
1027 $prune_it = false;
1028 }
1029
1030 if ($remote_sent) {
1031 $prune_it = true;
1032 }
1033
1034 // All non-auto backups must be run through this filter (in date order) regardless of the current state of $prune_it - so that filters are able to track state.
1035 $prune_it_before_filter = $prune_it;
1036 if (!$is_autobackup) $prune_it = apply_filters('updraftplus_prune_or_not', $prune_it, 'files', $backup_datestamp, $entity, $file_entities_backups_found[$entity], $rule, $group_id);
1037
1038 // The "more than maximum to keep?" counter should not be increased until we actually know that the set is being kept. Before version 1.11.22, we checked this before running the filter, which resulted in the counter being increased for sets that got pruned via the filter (i.e. not kept) - and too many backups were thus deleted
1039 if (!$prune_it && !$is_autobackup) {
1040 if ($file_entities_backups_found[$entity] >= $updraft_retain) {
1041 $this->log_with_db_occasionally("$entity: over retain limit ($updraft_retain); will delete this file entity");
1042 $prune_it = true;
1043 }
1044 }
1045
1046 if ($prune_it) {
1047 if (!$prune_it_before_filter) $any_deleted_via_filter_yet = true;
1048 $prune_this = $backup_to_examine[$entity];
1049 if (is_string($prune_this)) $prune_this = array($prune_this);
1050
1051 foreach ($prune_this as $k => $prune_file) {
1052 if ($remote_sent) {
1053 $updraftplus->log("$entity: $backup_datestamp: was sent to remote site; will remove from local record (only)");
1054 }
1055 $size_key = (0 == $k) ? $entity.'-size' : $entity.$k.'-size';
1056 $size = (isset($backup_to_examine[$size_key])) ? $backup_to_examine[$size_key] : null;
1057 $files_to_prune[] = $prune_file;
1058 $file_sizes[] = $size;
1059 }
1060 unset($backup_to_examine[$entity]);
1061
1062 } elseif (!$is_autobackup) {
1063 $file_entities_backups_found[$entity]++;
1064 }
1065 }
1066 }
1067
1068 // Sending an empty array is not itself a problem - except that the remote storage method may not check that before setting up a connection, which can waste time: especially if this is done every time around the loop.
1069 if (!empty($files_to_prune)) {
1070 // Actually delete the files
1071 foreach ($services as $service => $instance_ids_to_prune) {
1072 foreach ($instance_ids_to_prune as $instance_id_to_prune => $sd) {
1073 $this->current_pruning_instance_id = $instance_id_to_prune;
1074 if ("none" != $service && '' != $service && $sd[0]->supports_feature('multi_options')) {
1075 $storage_objects_and_ids = UpdraftPlus_Storage_Methods_Interface::get_storage_objects_and_ids(array($service));
1076 if ('all' == $instance_id_to_prune) {
1077 foreach ($storage_objects_and_ids[$service]['instance_settings'] as $saved_instance_id => $options) {
1078 $this->current_pruning_instance_id = $saved_instance_id;
1079 $sd[0]->set_options($options, false, $saved_instance_id);
1080 $this->prune_file($service, $files_to_prune, $sd[0], $sd[1], $file_sizes);
1081 }
1082 } else {
1083 $opts = $storage_objects_and_ids[$service]['instance_settings'][$instance_id_to_prune];
1084 $sd[0]->set_options($opts, false, $instance_id_to_prune);
1085 $this->prune_file($service, $files_to_prune, $sd[0], $sd[1], $file_sizes);
1086 }
1087 } else {
1088 $this->current_pruning_instance_id = empty($service) ? 'none' : $service;
1089 $this->prune_file($service, $files_to_prune, $sd[0], $sd[1], $file_sizes);
1090 }
1091 UpdraftPlus_Job_Scheduler::record_still_alive();
1092 }
1093 }
1094 }
1095 $this->current_pruning_instance_id = '';
1096
1097 $backup_to_examine = $this->remove_backup_set_if_empty($backup_to_examine, $backupable_entities);
1098 if (empty($backup_to_examine)) {
1099 unset($backup_history[$backup_datestamp]);
1100 $this->maybe_save_backup_history_and_reschedule($backup_history);
1101 } else {
1102 $backup_history[$backup_datestamp] = $backup_to_examine;
1103 }
1104
1105 // Loop over backup sets
1106 }
1107
1108 // Look over backup groups
1109 }
1110
1111 $updraftplus->log("Retain: saving new backup history (sets now: ".count($backup_history).") and finishing retain operation");
1112 UpdraftPlus_Backup_History::save_history($backup_history, false);
1113
1114 do_action('updraftplus_prune_retained_backups_finished');
1115
1116 $updraftplus->jobdata_set('prune', 'finished');
1117
1118 }
1119
1120 /**
1121 * The purpose of this is to save the backup history periodically - for the benefit of setups where the pruning takes longer than the total allow run time (e.g. if the network communications to the remote storage have delays in, and there are a lot of sets to scan)
1122 *
1123 * @param Array $backup_history - the backup history to possible save
1124 */
1125 private function maybe_save_backup_history_and_reschedule($backup_history) {
1126 static $last_saved_at = 0;
1127 if (!$last_saved_at) $last_saved_at = time();
1128 if (time() - $last_saved_at >= 10) {
1129 global $updraftplus;
1130 $updraftplus->log("Retain: saving new backup history, because at least 10 seconds have passed since the last save (sets now: ".count($backup_history).")");
1131 UpdraftPlus_Backup_History::save_history($backup_history, false);
1132 UpdraftPlus_Job_Scheduler::something_useful_happened();
1133 $last_saved_at = time();
1134 }
1135 }
1136
1137 /**
1138 * Examine a backup set; if it is empty (no files or DB), then remove the associated log file
1139 *
1140 * @param Array $backup_to_examine - backup set
1141 * @param Array $backupable_entities - compare with this list of backup entities
1142 *
1143 * @return Array|Boolean - if it was empty, false is returned
1144 */
1145 private function remove_backup_set_if_empty($backup_to_examine, $backupable_entities) {
1146
1147 global $updraftplus;
1148
1149 // Get new result, post-deletion; anything left in this set?
1150 $contains_files = 0;
1151 foreach ($backupable_entities as $entity => $info) {
1152 if (isset($backup_to_examine[$entity])) {
1153 $contains_files = 1;
1154 break;
1155 }
1156 }
1157
1158 $contains_db = 0;
1159 foreach ($backup_to_examine as $key => $data) {
1160 if ('db' == strtolower(substr($key, 0, 2)) && '-size' != substr($key, -5, 5)) {
1161 $contains_db = 1;
1162 break;
1163 }
1164 }
1165
1166 // Delete backup set completely if empty, o/w just remove DB
1167 // We search on the four keys which represent data, allowing other keys to be used to track other things
1168 if (!$contains_files && !$contains_db) {
1169 $updraftplus->log("This backup set is now empty; will remove from history");
1170 if (isset($backup_to_examine['nonce'])) {
1171 $fullpath = $this->updraft_dir."/log.".$backup_to_examine['nonce'].".txt";
1172 if (is_file($fullpath)) {
1173 $updraftplus->log("Deleting log file (log.".$backup_to_examine['nonce'].".txt)");
1174 @unlink($fullpath);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
1175 } else {
1176 $updraftplus->log("Corresponding log file (log.".$backup_to_examine['nonce'].".txt) not found - must have already been deleted");
1177 }
1178 } else {
1179 $updraftplus->log("No nonce record found in the backup set, so cannot delete any remaining log file");
1180 }
1181 return false;
1182 } else {
1183 $updraftplus->log("This backup set remains non-empty (f=$contains_files/d=$contains_db); will retain in history");
1184 return $backup_to_examine;
1185 }
1186
1187 }
1188
1189 /**
1190 * Prune files from remote and local storage
1191 *
1192 * @param String $service Service to prune (one only)
1193 * @param Array|String $dofiles An array of files (or a single string for one file)
1194 * @param Array $method_object specific method object
1195 * @param Array $object_passback specific passback object
1196 * @param Array $file_sizes size of files
1197 */
1198 private function prune_file($service, $dofiles, $method_object = null, $object_passback = null, $file_sizes = array()) {
1199 global $updraftplus;
1200 if (!is_array($dofiles)) $dofiles = array($dofiles);
1201
1202 if (!apply_filters('updraftplus_prune_file', true, $dofiles, $service, $method_object, $object_passback, $file_sizes)) {
1203 $updraftplus->log("Prune: service=$service: skipped via filter");
1204 return;
1205 }
1206
1207 foreach ($dofiles as $dofile) {
1208 $fullpath = $this->updraft_dir.'/'.$dofile;
1209 if (empty($dofile) || !file_exists($fullpath)) continue;
1210 $updraftplus->log("Delete file: $dofile, service=$service");
1211 // delete it if it's locally available
1212 $updraftplus->log("Deleting local copy ($dofile)");
1213 unlink($fullpath);
1214 if (file_exists($fullpath.'.list.tmp')) {
1215 $updraftplus->log("Deleting zip manifest ({$dofile}.list.tmp)");
1216 unlink($fullpath.'.list.tmp');
1217 }
1218 }
1219 // Despatch to the particular method's deletion routine
1220 if (!is_null($method_object)) {
1221 $files = $sizes = array();
1222 $pruning_start_time_for_current_instance_id = microtime(true);
1223 foreach ($dofiles as $actual_index => $file) {
1224 $hashed_file = md5($this->current_pruning_instance_id.$file);
1225 if (in_array($hashed_file, $this->hash_list_of_complete_pruned_files)) continue;
1226 $files[] = $file;
1227 $sizes[] = $file_sizes[$actual_index];
1228 $this->hash_list_of_complete_pruned_files[] = $hashed_file;
1229 if (count($dofiles) !== $actual_index+1 && 0 != ($actual_index+1) % $this->pruning_number_of_files) continue; // only proceed with deletion at every multiples of ten (default) or if it's already at the end of files
1230 $method_object->delete($files, $object_passback, $sizes);
1231 if (count($dofiles) === $actual_index+1 || (floor(microtime(true)-$pruning_start_time_for_current_instance_id)) >= $this->minimum_time_to_cache_pruned_files) {
1232 $this->hash_list_of_complete_pruned_files = array_unique($this->hash_list_of_complete_pruned_files);
1233 $updraftplus->jobdata_set('hash_list_of_complete_pruned_files', $this->hash_list_of_complete_pruned_files); // only cache already-done files when it reaches the minimum time, which is 25 seconds by default
1234 $pruning_start_time_for_current_instance_id = microtime(true);
1235 }
1236 $files = $sizes = array();
1237 }
1238 }
1239 }
1240
1241 /**
1242 * Log the amount account space free/used, if possible
1243 */
1244 private function log_account_space() {
1245 global $updraftplus;
1246 $hosting_bytes_free = $updraftplus->get_hosting_disk_quota_free();
1247 if (is_array($hosting_bytes_free)) {
1248 $perc = round(100*$hosting_bytes_free[1]/(max($hosting_bytes_free[2], 1)), 1);
1249 $updraftplus->log(sprintf('Free disk space in account: %s (%s used)', round($hosting_bytes_free[3]/1048576, 1)." MB", "$perc %"));
1250 }
1251 }
1252
1253 /**
1254 * Returns the basename up to and including the nonce (but not the entity)
1255 *
1256 * @param Integer $use_time epoch time to use
1257 * @return String
1258 */
1259 private function get_backup_file_basename_from_time($use_time) {
1260 global $updraftplus;
1261 return apply_filters('updraftplus_get_backup_file_basename_from_time', 'backup_'.get_date_from_gmt(gmdate('Y-m-d H:i:s', $use_time), 'Y-m-d-Hi').'_'.$this->site_name.'_'.$updraftplus->file_nonce, $use_time, $this->site_name);
1262 }
1263
1264 /**
1265 * Find the zip files in a given directory for a given nonce
1266 *
1267 * @param String $dir - directory to look in
1268 * @param String $match_nonce - backup ID to match
1269 *
1270 * @return Array
1271 */
1272 private function find_existing_zips($dir, $match_nonce) {
1273 $zips = array();
1274 if (!$handle = opendir($dir)) return $zips;
1275 while (false !== ($entry = readdir($handle))) {
1276 if ('.' == $entry || '..' == $entry) continue;
1277 if (preg_match('/^backup_(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})_.*_([0-9a-f]{12})-([\-a-z]+)([0-9]+)?\.zip$/i', $entry, $matches)) {
1278 if ($matches[6] !== $match_nonce) continue;
1279 $timestamp = mktime($matches[4], $matches[5], 0, $matches[2], $matches[3], $matches[1]);
1280 $entity = $matches[7];
1281 $index = empty($matches[8]) ? '0' : $matches[8];
1282 $zips[$entity][$index] = array($timestamp, $entry);
1283 }
1284 }
1285 return $zips;
1286 }
1287
1288 /**
1289 * Get information on whether a particular file exists in a set
1290 *
1291 * @param Array $files should be an array as returned by find_existing_zips()]
1292 * @param String $entity entty of the file (e.g. 'plugins')
1293 * @param Integer $index Index within the files array
1294 * @return String|Boolean - false if the file does not exist; otherwise, the basename
1295 */
1296 private function file_exists($files, $entity, $index = 0) {
1297 if (isset($files[$entity]) && isset($files[$entity][$index])) {
1298 $file = $files[$entity][$index];
1299 // Return the filename
1300 return $file[1];
1301 } else {
1302 return false;
1303 }
1304 }
1305
1306 /**
1307 * This function is resumable
1308 *
1309 * @param String $job_status Current status
1310 *
1311 * @return Array - array of backed-up files
1312 */
1313 private function backup_dirs($job_status) {
1314
1315 global $updraftplus;
1316
1317 if (!$updraftplus->backup_time) $updraftplus->backup_time_nonce();
1318
1319 $use_time = $updraftplus->backup_time;
1320 $backup_file_basename = $this->get_backup_file_basename_from_time($use_time);
1321
1322 $backup_array = array();
1323
1324 // Was there a check-in last time? If not, then reduce the amount of data attempted
1325 if ('finished' != $job_status && $updraftplus->current_resumption >= 2) {
1326
1327 // NOTYET: Possible amendment to original algorithm; not just no check-in, but if the check in was very early (can happen if we get a very early checkin for some trivial operation, then attempt something too big)
1328
1329 // 03-Sep-2015 - came across a case (HS#2052) where there apparently was a check-in 'last time', but no resumption was scheduled because the 'useful_checkin' jobdata was *not* last time - which must indicate dying at a very unfortunate/unlikely point in the code. As a result, the split was not auto-reduced. Consequently, we've added !$updraftplus->newresumption_scheduled as a condition on the first check here (it was already on the second), as if no resumption is scheduled then whatever checkin there was last time was only partial. This was on GoDaddy, for which a number of curious I/O event combinations have been seen in recent months - their platform appears to have some odd behaviour when PHP is killed off.
1330 // 04-Sep-2015 - move the '$updraftplus->current_resumption<=10' check to the inner loop (instead of applying to this whole section), as I see no reason for that restriction (case seen in HS#2064 where it was required on resumption 15)
1331 if ($updraftplus->no_checkin_last_time || !$updraftplus->newresumption_scheduled || $updraftplus->resumption_scheduled_for_cleanup) {
1332 // Apr 2015: !$updraftplus->newresumption_scheduled added after seeing a log where there was no activity on resumption 9, and extra resumption 10 then tried the same operation.
1333 if ($updraftplus->current_resumption - $updraftplus->last_successful_resumption > 2 || !$updraftplus->newresumption_scheduled) {
1334 $this->try_split = true;
1335 } elseif ($updraftplus->current_resumption <= 10) {
1336 $maxzipbatch = $updraftplus->jobdata_get('maxzipbatch', 26214400);
1337 if ((int) $maxzipbatch < 1) $maxzipbatch = 26214400;
1338 $new_maxzipbatch = max(floor($maxzipbatch * 0.75), 20971520);
1339 if ($new_maxzipbatch < $maxzipbatch) {
1340 $updraftplus->log("No check-in was detected on the previous run - as a result, we are reducing the batch amount (old=$maxzipbatch, new=$new_maxzipbatch)");
1341 $updraftplus->jobdata_set('maxzipbatch', $new_maxzipbatch);
1342 $updraftplus->jobdata_set('maxzipbatch_ceiling', $new_maxzipbatch);
1343 }
1344 }
1345 }
1346 }
1347
1348 if ('finished' != $job_status && !UpdraftPlus_Filesystem_Functions::really_is_writable($this->updraft_dir)) {
1349 $updraftplus->log("Backup directory (".$this->updraft_dir.") is not writable, or does not exist");
1350 /* translators: %s: Backup directory path */
1351 $updraftplus->log(sprintf(__("Backup directory (%s) is not writable, or does not exist.", 'updraftplus'), $this->updraft_dir), 'error');
1352 return array();
1353 }
1354
1355 $this->job_file_entities = $updraftplus->jobdata_get('job_file_entities');
1356
1357 // This is just used for the visual feedback (via the 'substatus' key)
1358 $which_entity = 0;
1359 // e.g. plugins, themes, uploads, others
1360 // $whichdir might be an array (if $youwhat is 'more')
1361
1362 // Returns an array (keyed off the entity) of ($timestamp, $filename) arrays
1363 $existing_zips = $this->find_existing_zips($this->updraft_dir, $updraftplus->file_nonce);
1364
1365 $possible_backups = $updraftplus->get_backupable_file_entities(true);
1366
1367 foreach ($possible_backups as $youwhat => $whichdir) {
1368
1369 if (!isset($this->job_file_entities[$youwhat])) {
1370 $updraftplus->log("No backup of $youwhat: excluded by user's options");
1371 continue;
1372 }
1373
1374 $index = (int) $this->job_file_entities[$youwhat]['index'];
1375 if (empty($index)) $index=0;
1376 $indextext = (0 == $index) ? '' : (1+$index);
1377
1378 $zip_file = $this->updraft_dir.'/'.$backup_file_basename.'-'.$youwhat.$indextext.'.zip';
1379
1380 // Split needed?
1381 $split_every = max((int) $updraftplus->jobdata_get('split_every'), 250);
1382
1383 if (false != ($existing_file = $this->file_exists($existing_zips, $youwhat, $index)) && filesize($this->updraft_dir.'/'.$existing_file) > $split_every*1048576) {
1384 $index++;
1385 $this->job_file_entities[$youwhat]['index'] = $index;
1386 $updraftplus->jobdata_set('job_file_entities', $this->job_file_entities);
1387 }
1388
1389 // Populate prior parts of $backup_array, if we're on a subsequent zip file
1390 if ($index > 0) {
1391 for ($i=0; $i<$index; $i++) {
1392 $itext = (0 == $i) ? '' : ($i+1);
1393 // Get the previously-stored filename if possible (which should be always); failing that, base it on the current run
1394
1395 $zip_file = (isset($this->backup_files_array[$youwhat]) && isset($this->backup_files_array[$youwhat][$i])) ? $this->backup_files_array[$youwhat][$i] : $backup_file_basename.'-'.$youwhat.$itext.'.zip';
1396
1397 $backup_array[$youwhat][$i] = $zip_file;
1398 $z = $this->updraft_dir.'/'.$zip_file;
1399 $itext = (0 == $i) ? '' : $i;
1400
1401 $fs_key = $youwhat.$itext.'-size';
1402 if (file_exists($z)) {
1403 $backup_array[$fs_key] = filesize($z);
1404 } elseif (isset($this->backup_files_array[$fs_key])) {
1405 $backup_array[$fs_key] = $this->backup_files_array[$fs_key];
1406 }
1407 }
1408 }
1409
1410 // I am not certain that all the conditions in here are possible. But there's no harm.
1411 if ('finished' == $job_status) {
1412 // Add the final part of the array
1413 if ($index > 0) {
1414 $zip_file = (isset($this->backup_files_array[$youwhat]) && isset($this->backup_files_array[$youwhat][$index])) ? $this->backup_files_array[$youwhat][$index] : $backup_file_basename.'-'.$youwhat.($index+1).'.zip';
1415 $z = $this->updraft_dir.'/'.$zip_file;
1416 $fs_key = $youwhat.$index.'-size';
1417 $backup_array[$youwhat][$index] = $zip_file;
1418 if (file_exists($z)) {
1419 $backup_array[$fs_key] = filesize($z);
1420 } elseif (isset($this->backup_files_array[$fs_key])) {
1421 $backup_array[$fs_key] = $this->backup_files_array[$fs_key];
1422 }
1423 } else {
1424 $zip_file = (isset($this->backup_files_array[$youwhat]) && isset($this->backup_files_array[$youwhat][0])) ? $this->backup_files_array[$youwhat][0] : $backup_file_basename.'-'.$youwhat.'.zip';
1425
1426 $backup_array[$youwhat] = $zip_file;
1427 $fs_key=$youwhat.'-size';
1428
1429 if (file_exists($zip_file)) {
1430 $backup_array[$fs_key] = filesize($zip_file);
1431 } elseif (isset($this->backup_files_array[$fs_key])) {
1432 $backup_array[$fs_key] = $this->backup_files_array[$fs_key];
1433 }
1434 }
1435 } else {
1436
1437 $which_entity++;
1438 $updraftplus->jobdata_set('filecreating_substatus', array('e' => $youwhat, 'i' => $which_entity, 't' => count($this->job_file_entities)));
1439
1440 if ('others' == $youwhat) $updraftplus->log("Beginning backup of other directories found in the content directory (index: $index)");
1441
1442 // Apply a filter to allow add-ons to provide their own method for creating a zip of the entity
1443 $created = apply_filters('updraftplus_backup_makezip_'.$youwhat, $whichdir, $backup_file_basename, $index);
1444
1445 // If the filter did not lead to something being created, then use the default method
1446 if ($created === $whichdir) {
1447
1448 // http://www.phpconcept.net/pclzip/user-guide/53
1449 /* First parameter to create is:
1450 An array of filenames or dirnames,
1451 or
1452 A string containing the filename or a dirname,
1453 or
1454 A string containing a list of filename or dirname separated by a comma.
1455 */
1456
1457 if ('others' == $youwhat) {
1458 $dirlist = $updraftplus->backup_others_dirlist(true);
1459 } elseif ('uploads' == $youwhat) {
1460 $dirlist = $updraftplus->backup_uploads_dirlist(true);
1461 } else {
1462 $dirlist = $whichdir;
1463 if (is_array($dirlist)) $dirlist = array_shift($dirlist);
1464 }
1465
1466 if (!empty($dirlist)) {
1467 $created = $this->create_zip($dirlist, $youwhat, $backup_file_basename, $index);
1468 // Now, store the results
1469 if (!is_string($created) && !is_array($created)) $updraftplus->log("$youwhat: create_zip returned an error");
1470 } else {
1471 $updraftplus->log("No backup of $youwhat: there was nothing found to backup");
1472 }
1473 }
1474
1475 if ($created != $whichdir && (is_string($created) || is_array($created))) {
1476 if (is_string($created)) $created =array($created);
1477 foreach ($created as $fname) {
1478 if (isset($backup_array[$youwhat]) && in_array($fname, $backup_array[$youwhat])) continue;
1479 $backup_array[$youwhat][$index] = $fname;
1480 $itext = (0 == $index) ? '' : $index;
1481 // File may have already been uploaded and removed so get the size from jobdata
1482 if (file_exists($this->updraft_dir.'/'.$fname)) {
1483 $backup_array[$youwhat.$itext.'-size'] = filesize($this->updraft_dir.'/'.$fname);
1484 } else {
1485 $backup_array[$youwhat.$itext.'-size'] = $updraftplus->jobdata_get('filesize-'.$youwhat.$index);
1486 }
1487 $index++;
1488 }
1489 }
1490
1491 $this->job_file_entities[$youwhat]['index'] = $this->index;
1492 $updraftplus->jobdata_set('job_file_entities', $this->job_file_entities);
1493
1494 }
1495 }
1496
1497 return $backup_array;
1498 }
1499
1500 /**
1501 * This uses a saved status indicator; its only purpose is to indicate *total* completion; there is no actual danger, just wasted time, in resuming when it was not needed. So the saved status indicator just helps save resources.
1502 *
1503 * @param Integer $resumption_no Check for first run
1504 *
1505 * @return Array
1506 */
1507 public function resumable_backup_of_files($resumption_no) {
1508 global $updraftplus;
1509 // Backup directories and return a numerically indexed array of file paths to the backup files
1510 $bfiles_status = $updraftplus->jobdata_get('backup_files');
1511 $this->backup_files_array = $updraftplus->jobdata_get('backup_files_array');
1512
1513 if (!is_array($this->backup_files_array)) $this->backup_files_array = array();
1514 if ('finished' == $bfiles_status) {
1515 $updraftplus->log("Creation of backups of directories: already finished");
1516 // Check for recent activity
1517 foreach ($this->backup_files_array as $files) {
1518 if (!is_array($files)) $files =array($files);
1519 foreach ($files as $file) $updraftplus->check_recent_modification($this->updraft_dir.'/'.$file);
1520 }
1521 } elseif ('begun' == $bfiles_status) {
1522 $this->first_run = apply_filters('updraftplus_filerun_firstrun', 0);
1523 if ($resumption_no > $this->first_run) {
1524 $updraftplus->log("Creation of backups of directories: had begun; will resume");
1525 } else {
1526 $updraftplus->log("Creation of backups of directories: beginning");
1527 }
1528 $updraftplus->jobdata_set('jobstatus', 'filescreating');
1529 $this->backup_files_array = $this->backup_dirs($bfiles_status);
1530 $updraftplus->jobdata_set('backup_files_array', $this->backup_files_array);
1531 $updraftplus->jobdata_set('backup_files', 'finished');
1532 $updraftplus->jobdata_set('jobstatus', 'filescreated');
1533 } else {
1534 // This is not necessarily a backup run which is meant to contain files at all
1535 $updraftplus->log('This backup run is not intended for files - skipping');
1536 return array();
1537 }
1538
1539 /*
1540 // DOES NOT WORK: there is no crash-safe way to do this here - have to be renamed at cloud-upload time instead
1541 $new_backup_array = array();
1542 foreach ($backup_array as $entity => $files) {
1543 if (!is_array($files)) $files=array($files);
1544 $outof = count($files);
1545 foreach ($files as $ind => $file) {
1546 $nval = $file;
1547 if (preg_match('/^(backup_[\-0-9]{15}_.*_[0-9a-f]{12}-[\-a-z]+)([0-9]+)?\.zip$/i', $file, $matches)) {
1548 $num = max((int)$matches[2],1);
1549 $new = $matches[1].$num.'of'.$outof.'.zip';
1550 if (file_exists($this->updraft_dir.'/'.$file)) {
1551 if (@rename($this->updraft_dir.'/'.$file, $this->updraft_dir.'/'.$new)) {
1552 $updraftplus->log(sprintf("Renaming: %s to %s", $file, $new));
1553 $nval = $new;
1554 }
1555 } elseif (file_exists($this->updraft_dir.'/'.$new)) {
1556 $nval = $new;
1557 }
1558 }
1559 $new_backup_array[$entity][$ind] = $nval;
1560 }
1561 }
1562 */
1563 return $this->backup_files_array;
1564 }
1565
1566 /**
1567 * This function is resumable, using the following method:
1568 * Each table is written out to ($final_filename).table.tmp
1569 * When the writing finishes, it is renamed to ($final_filename).table
1570 * When all tables are finished, they are concatenated into the final file
1571 *
1572 * @param String $already_done Status of backup
1573 * @param String $whichdb Indicated which database is being backed up
1574 * @param Array $dbinfo is only used when whichdb != 'wp'; and the keys should be: user, pass, name, host, prefix
1575 *
1576 * @return Boolean|String - the basename of the database backup, or false for failure
1577 */
1578 public function backup_db($already_done = 'begun', $whichdb = 'wp', $dbinfo = array()) {
1579
1580 global $updraftplus, $wpdb;
1581
1582 $this->whichdb = $whichdb;
1583 $this->whichdb_suffix = ('wp' == $whichdb) ? '' : $whichdb;
1584
1585 if (!$updraftplus->backup_time) $updraftplus->backup_time_nonce();
1586 if (!$updraftplus->opened_log_time) $updraftplus->logfile_open($updraftplus->nonce);
1587
1588 if ('wp' == $this->whichdb) {
1589 $this->wpdb_obj = $wpdb;
1590 // The table prefix after being filtered - i.e. what filters what we'll actually backup
1591 $this->table_prefix = $updraftplus->get_table_prefix(true);
1592 // The unfiltered table prefix - i.e. the real prefix that things are relative to
1593 $this->table_prefix_raw = $updraftplus->get_table_prefix(false);
1594 $dbinfo['host'] = DB_HOST;
1595 $dbinfo['name'] = DB_NAME;
1596 $dbinfo['user'] = DB_USER;
1597 $dbinfo['pass'] = DB_PASSWORD;
1598 } else {
1599 if (!is_array($dbinfo) || empty($dbinfo['host'])) return false;
1600 // The methods that we may use: check_connection (WP>=3.9), get_results, get_row, query
1601 $this->wpdb_obj = new UpdraftPlus_WPDB_OtherDB($dbinfo['user'], $dbinfo['pass'], $dbinfo['name'], $dbinfo['host']);
1602 if (!empty($this->wpdb_obj->error)) {
1603 $updraftplus->log($dbinfo['user'].'@'.$dbinfo['host'].'/'.$dbinfo['name'].' : database connection attempt failed');
1604 $updraftplus->log($dbinfo['user'].'@'.$dbinfo['host'].'/'.$dbinfo['name'].' : '.__('database connection attempt failed.', 'updraftplus').' '.__('Connection failed: check your access details, that the database server is up, and that the network connection is not firewalled.', 'updraftplus'), 'error');
1605 return $updraftplus->log_wp_error($this->wpdb_obj->error);
1606 }
1607 $this->table_prefix = $dbinfo['prefix'];
1608 $this->table_prefix_raw = $dbinfo['prefix'];
1609 }
1610
1611 $this->dbinfo = $dbinfo;
1612
1613 do_action('updraftplus_backup_db_begin', $whichdb, $dbinfo, $already_done, $this);
1614
1615 UpdraftPlus_Database_Utility::set_sql_mode(array(), array('ANSI_QUOTES'), $this->wpdb_obj);
1616
1617 $errors = 0;
1618
1619 $use_time = apply_filters('updraftplus_base_backup_timestamp', $updraftplus->backup_time);
1620 $file_base = $this->get_backup_file_basename_from_time($use_time);
1621 $backup_file_base = $this->updraft_dir.'/'.$file_base;
1622
1623 if ('finished' == $already_done) return basename($backup_file_base).'-db'.(('wp' == $whichdb) ? '' : $whichdb).'.gz';
1624 if ('encrypted' == $already_done) return basename($backup_file_base).'-db'.(('wp' == $whichdb) ? '' : $whichdb).'.gz.crypt';
1625
1626 $updraftplus->jobdata_set('jobstatus', 'dbcreating'.$this->whichdb_suffix);
1627
1628 $binsqldump = $updraftplus->find_working_sqldump();
1629
1630 $total_tables = 0;
1631
1632 // WP 3.9 onwards - https://core.trac.wordpress.org/browser/trunk/src/wp-includes/wp-db.php?rev=27925 - check_connection() allows us to get the database connection back if it had dropped
1633 if ('wp' == $whichdb && method_exists($this->wpdb_obj, 'check_connection') && (!defined('UPDRAFTPLUS_SUPPRESS_CONNECTION_CHECKS') || !UPDRAFTPLUS_SUPPRESS_CONNECTION_CHECKS)) {
1634 if (!$this->wpdb_obj->check_connection(false)) {
1635 UpdraftPlus_Job_Scheduler::reschedule(60);
1636 $updraftplus->log("It seems the database went away; scheduling a resumption and terminating for now");
1637 UpdraftPlus_Job_Scheduler::record_still_alive();
1638 die;
1639 }
1640 }
1641
1642 // SHOW FULL - so that we get to know whether it's a BASE TABLE or a VIEW
1643 $all_tables = $this->wpdb_obj->get_results("SHOW FULL TABLES", ARRAY_N);
1644
1645 if (empty($all_tables) && !empty($this->wpdb_obj->last_error)) {
1646 $all_tables = $this->wpdb_obj->get_results("SHOW TABLES", ARRAY_N);
1647 $all_tables = array_map(array($this, 'cb_get_name_base_type'), $all_tables);
1648 } else {
1649 $all_tables = array_map(array($this, 'cb_get_name_type'), $all_tables);
1650 }
1651
1652 // If this is not the WP database, then we do not consider it a fatal error if there are no tables
1653 if ('wp' == $whichdb && 0 == count($all_tables)) {
1654 $extra = ($updraftplus->newresumption_scheduled) ? ' - '.__('please wait for the rescheduled attempt', 'updraftplus') : '';
1655 $updraftplus->log("Error: No WordPress database tables found (SHOW TABLES returned nothing)".$extra);
1656 $updraftplus->log(__("No database tables found", 'updraftplus').$extra, 'error');
1657 die;
1658 }
1659
1660 // Put the options table first
1661 UpdraftPlus_Database_Utility::init($this->whichdb, $this->table_prefix_raw, $this->wpdb_obj);
1662 usort($all_tables, array('UpdraftPlus_Database_Utility', 'backup_db_sorttables'));
1663
1664 $all_table_names = array_map(array($this, 'cb_get_name'), $all_tables);
1665
1666 if (!UpdraftPlus_Filesystem_Functions::really_is_writable($this->updraft_dir)) {
1667 $updraftplus->log("The backup directory (".$this->updraft_dir.") could not be written to (could be account/disk space full, or wrong permissions).");
1668 $updraftplus->log($this->updraft_dir.": ".__('The backup directory is not writable (or disk space is full) - the database backup is expected to shortly fail.', 'updraftplus'), 'warning');
1669 // Why not just fail now? We saw a bizarre case when the results of really_is_writable() changed during the run.
1670 }
1671
1672 // This check doesn't strictly get all possible duplicates; it's only designed for the case that can happen when moving between deprecated Windows setups and Linux
1673 $this->duplicate_tables_exist = false;
1674 foreach ($all_table_names as $table) {
1675 if (strtolower($table) != $table && in_array(strtolower($table), $all_table_names)) {
1676 $this->duplicate_tables_exist = true;
1677 $updraftplus->log("Tables with names differing only based on case-sensitivity exist in the MySQL database: $table / ".strtolower($table));
1678 }
1679 }
1680 $how_many_tables = count($all_tables);
1681
1682 $stitch_files = array();
1683 $found_options_table = false;
1684 $is_multisite = is_multisite();
1685
1686 $anonymisation_options = $updraftplus->jobdata_get('anonymisation_options', array());
1687
1688 if (!empty($anonymisation_options)) {
1689 $updraftplus->log("Anonymisation options have been set, so mysqldump (which does not support them) will be disabled.");
1690 }
1691
1692 // Gather the list of files that look like partial table files once only
1693 $potential_stitch_files = array();
1694 $table_file_prefix_base= $file_base.'-db'.$this->whichdb_suffix.'-table-';
1695 if (false !== ($dir_handle = opendir($this->updraft_dir))) {
1696 while (false !== ($e = readdir($dir_handle))) {
1697 // The 'r' in 'tmpr' indicates that the new scheme is being used. N.B. That does *not* imply that the table has a usable primary key.
1698 if (!is_file($this->updraft_dir.'/'.$e)) continue;
1699 if (preg_match('#'.$table_file_prefix_base.'.*\.table\.tmpr?(\d+)\.gz$#', $e, $matches)) {
1700 // We need to stich them in order
1701 $potential_stitch_files[] = $e;
1702 }
1703 }
1704 } else {
1705 $updraftplus->log("Error: Failed to open directory for reading");
1706 $updraftplus->log(__("Failed to open directory for reading:", 'updraftplus').' '.$this->updraft_dir, 'error');
1707 }
1708
1709 $errors_at_all_tables_start = $updraftplus->error_count();
1710
1711 foreach ($all_tables as $ti) {
1712
1713 $table = $ti['name'];
1714 $stitch_files[$table] = array();
1715 $table_type = $ti['type'];
1716 $errors_at_table_start = $updraftplus->error_count();
1717
1718 $this->many_rows_warning = false;
1719 $total_tables++;
1720
1721 // Increase script execution time-limit to 15 min for every table.
1722 if (function_exists('set_time_limit')) @set_time_limit(UPDRAFTPLUS_SET_TIME_LIMIT);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
1723 // The table file may already exist if we have produced it on a previous run
1724 $table_file_prefix = $file_base.'-db'.$this->whichdb_suffix.'-table-'.$table.'.table';
1725
1726 if ('wp' == $whichdb && (strtolower($this->table_prefix_raw.'options') == strtolower($table) || ($is_multisite && (strtolower($this->table_prefix_raw.'sitemeta') == strtolower($table) || strtolower($this->table_prefix_raw.'1_options') == strtolower($table))))) $found_options_table = true;
1727
1728 // Already finished?
1729 if (file_exists($this->updraft_dir.'/'.$table_file_prefix.'.gz')) {
1730 $stitched = count($stitch_files, COUNT_RECURSIVE);
1731 $skip_dblog = (($stitched > 10 && 0 != $stitched % 20) || ($stitched > 100 && 0 != $stitched % 100));
1732 $updraftplus->log("Table $table: corresponding file already exists; moving on", 'notice', false, $skip_dblog);
1733
1734 $max_record = false;
1735 foreach ($potential_stitch_files as $e) {
1736 // The 'r' in 'tmpr' indicates that the new scheme is being used. N.B. That does *not* imply that the table has a usable primary key.
1737 if (preg_match('#'.$table_file_prefix.'\.tmpr?(\d+)\.gz$#', $e, $matches)) {
1738 // We need to stich them in order
1739 $stitch_files[$table][$matches[1]] = $e;
1740 if (false === $max_record || $matches[1] > $max_record) $max_record = $matches[1];
1741 }
1742 }
1743 $stitch_files[$table][$max_record+1] = $table_file_prefix.'.gz';
1744
1745 // Move on to the next table
1746 continue;
1747 }
1748
1749 // === is needed with strpos/stripos, otherwise 'false' matches (i.e. prefix does not match)
1750 if (empty($this->table_prefix) || (!$this->duplicate_tables_exist && 0 === stripos($table, $this->table_prefix)) || ($this->duplicate_tables_exist && 0 === strpos($table, $this->table_prefix))) {
1751
1752 // Skip table due to filter?
1753 if (!apply_filters('updraftplus_backup_table', true, $table, $this->table_prefix, $whichdb, $dbinfo)) {
1754 $updraftplus->log("Skipping table (filtered): $table");
1755 if (empty($this->skipped_tables)) $this->skipped_tables = array();
1756
1757 // whichdb could be an int in which case to get the name of the database and the array key use the name from dbinfo
1758 $key = ('wp' === $whichdb) ? 'wp' : $dbinfo['name'];
1759
1760 if (empty($this->skipped_tables[$key])) $this->skipped_tables[$key] = array();
1761 $this->skipped_tables[$key][] = $table;
1762
1763 $total_tables--;
1764 continue;
1765 }
1766
1767 add_filter('updraftplus_backup_table_sql_where', array($this, 'backup_exclude_jobdata'), 10, 3);
1768
1769 $updraftplus->jobdata_set('dbcreating_substatus', array('t' => $table, 'i' => $total_tables, 'a' => $how_many_tables));
1770
1771 // .tmp.gz is the current temporary file. When the row limit has been reached, it is moved to .tmp1.gz, .tmp2.gz, etc. (depending on which already exist). When we're all done, then they all get stitched in.
1772
1773 $db_temp_file = $this->updraft_dir.'/'.$table_file_prefix.'.tmp.gz';
1774 $updraftplus->check_recent_modification($db_temp_file);
1775
1776 // Open file, store the handle
1777 if (false === $this->backup_db_open($db_temp_file, true)) return false;
1778
1779 $table_status = $this->wpdb_obj->get_row("SHOW TABLE STATUS WHERE Name='$table'");
1780
1781 // Create the preceding SQL statements for the table
1782 $this->stow("# " . sprintf('Table: %s', UpdraftPlus_Manipulation_Functions::backquote($table)) . "\n");
1783
1784 // Meaning: false = don't yet know; true = know and have logged it; integer = the expected number
1785 $this->expected_rows = false;
1786
1787 if (isset($table_status->Rows)) {
1788 $this->expected_rows = $table_status->Rows;
1789 }
1790
1791 // If no check-in last time, then we could in future try the other method (but - any point in retrying slow method on large tables??)
1792
1793 // New Jul 2014: This attempt to use bindump instead at a lower threshold is quite conservative - only if the last successful run was exactly two resumptions ago - may be useful to expand
1794 $bindump_threshold = (!$updraftplus->something_useful_happened && !empty($updraftplus->current_resumption) && (2 == $updraftplus->current_resumption - $updraftplus->last_successful_resumption)) ? 1000 : 8000;
1795
1796 if (isset($table_status->Rows) && ($table_status->Rows > $bindump_threshold || (defined('UPDRAFTPLUS_ALWAYS_TRY_MYSQLDUMP') && UPDRAFTPLUS_ALWAYS_TRY_MYSQLDUMP)) && is_string($binsqldump) && empty($anonymisation_options)) {
1797 if (!is_bool($this->expected_rows)) {
1798 $this->log_expected_rows($table, $this->expected_rows);
1799 $this->expected_rows = true;
1800 }
1801 $bindump = $this->backup_table_bindump($binsqldump, $table);
1802 } else {
1803 $bindump = false;
1804 }
1805
1806 // Means "start of table". N.B. The meaning of an integer depends upon whether the table has a usable primary key or not.
1807 $start_record = true;
1808 $can_use_primary_key = apply_filters('updraftplus_can_use_primary_key_default', true, $table);
1809 foreach ($potential_stitch_files as $e) {
1810 // The 'r' in 'tmpr' indicates that the new scheme is being used. N.B. That does *not* imply that the table has a usable primary key.
1811 if (preg_match('#'.$table_file_prefix.'\.tmp(r)?(\d+)\.gz$#', $e, $matches)) {
1812 $stitch_files[$table][$matches[2]] = $e;
1813 if (true === $start_record || $matches[2] > $start_record) $start_record = $matches[2];
1814 // Legacy scheme. The purpose of this is to prevent backups failing if one is in progress during an upgrade to a new version that implements the new scheme
1815 if ('r' !== $matches[1]) $can_use_primary_key = false;
1816 }
1817 }
1818
1819 // Legacy file-naming scheme in use
1820 if (false === $can_use_primary_key && true !== $start_record) {
1821 $start_record = ($start_record + 100) * 1000;
1822 }
1823
1824 if (true !== $bindump) {
1825
1826 while (!is_array($start_record) && !is_wp_error($start_record)) {
1827 $start_record = $this->backup_table($table, $table_type, $start_record, $can_use_primary_key);
1828 if (is_integer($start_record) || is_array($start_record)) {
1829
1830 $this->backup_db_close();
1831
1832 // Add one here in case no records were returned - don't want to over-write the previous file
1833 $use_record = is_array($start_record) ? (isset($start_record['next_record']) ? $start_record['next_record']+1 : false) : $start_record;
1834 if (!$can_use_primary_key) $use_record = (ceil($use_record/100000)-1) * 100;
1835
1836 if (false !== $use_record) {
1837 // N.B. Renaming using the *next* record is intentional - it allows UD to know where to resume from.
1838 $rename_base = $table_file_prefix.'.tmp'.($can_use_primary_key ? 'r' : '').$use_record.'.gz';
1839
1840 rename($db_temp_file, $this->updraft_dir.'/'.$rename_base);
1841 $stitch_files[$table][$use_record] = $rename_base;
1842 } elseif (is_array($start_record) && 'view' == strtolower($table_type)) {
1843 $rename_base = $table_file_prefix.'-view.tmp.gz';
1844 rename($db_temp_file, $this->updraft_dir.'/'.$rename_base);
1845 $stitch_files[$table][] = $rename_base;
1846 }
1847
1848 UpdraftPlus_Job_Scheduler::something_useful_happened();
1849
1850 if (false === $this->backup_db_open($db_temp_file, true)) return false;
1851
1852 } elseif (is_wp_error($start_record)) {
1853 $message = "Error (table=$table, type=$table_type) (".$start_record->get_error_code()."): ".$start_record->get_error_message();
1854 $updraftplus->log($message);
1855 // If it's a view, then the problem isn't recoverable; but views don't contain actual data except in the definition, which is likely in code, so we should not consider this a fatal error
1856 $level = 'error';
1857 if ('view' == strtolower($table_type) && 'table_details_error' == $start_record->get_error_code()) {
1858 $level = 'warning';
1859 $this->stow("# $message\n");
1860 }
1861 $updraftplus->log(__("Failed to backup database table:", 'updraftplus').' '.$start_record->get_error_message().' ('.$start_record->get_error_code().')', $level);
1862 }
1863
1864 }
1865 }
1866
1867 // If we got this far, then there were enough resources; the warning can be removed
1868 if (!empty($this->many_rows_warning)) $updraftplus->log_remove_warning('manyrows_'.$this->whichdb_suffix.$table);
1869
1870 $this->backup_db_close();
1871
1872 if ($updraftplus->error_count() > $errors_at_table_start) {
1873
1874 $updraftplus->log('Errors occurred during backing up the table; therefore the open file will be removed');
1875 @unlink($db_temp_file); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
1876
1877 } else {
1878
1879 // Renaming the file indicates that writing to it finished
1880 rename($db_temp_file, $this->updraft_dir.'/'.$table_file_prefix.'.gz');
1881 UpdraftPlus_Job_Scheduler::something_useful_happened();
1882
1883 $final_stitch_value = empty($stitch_files[$table]) ? 1 : max(array_keys($stitch_files[$table])) + 1;
1884
1885 $stitch_files[$table][$final_stitch_value] = $table_file_prefix.'.gz';
1886
1887 $total_db_size = 0;
1888 // This is more verbose than it would be if we weren't supporting PHP 5.2
1889 foreach ($stitch_files[$table] as $basename) {
1890 $total_db_size += filesize($this->updraft_dir.'/'.$basename);
1891 }
1892
1893 $updraftplus->log("Table $table: finishing file(s) (".count($stitch_files[$table]).', '.round($total_db_size/1024, 1).' KB)', 'notice', false, false);
1894 }
1895
1896 } else {
1897 $total_tables--;
1898 $updraftplus->log("Skipping table (lacks our prefix (".$this->table_prefix.")): $table");
1899 if (empty($this->skipped_tables)) $this->skipped_tables = array();
1900 // whichdb could be an int in which case to get the name of the database and the array key use the name from dbinfo
1901 $key = ('wp' === $whichdb) ? 'wp' : $dbinfo['name'];
1902 if (empty($this->skipped_tables[$key])) $this->skipped_tables[$key] = array();
1903 $this->skipped_tables[$key][] = $table;
1904 }
1905 }
1906
1907 if ('wp' == $whichdb) {
1908 if (!$found_options_table) {
1909 if ($is_multisite) {
1910 $updraftplus->log(__('The database backup has failed', 'updraftplus').' - '.__('no options or sitemeta table was found', 'updraftplus'), 'warning', 'optstablenotfound');
1911 } else {
1912 $updraftplus->log(__('The database backup has failed', 'updraftplus').' - '.__('the options table was not found', 'updraftplus'), 'warning', 'optstablenotfound');
1913 }
1914 $time_this_run = time()-$updraftplus->opened_log_time;
1915 if ($time_this_run > 2000) {
1916 // Have seen this happen; not sure how, but it was apparently deterministic; if the current process had been running for a long time, then apparently all database commands silently failed.
1917 // If we have been running that long, then the resumption may be far off; bring it closer
1918 UpdraftPlus_Job_Scheduler::reschedule(60);
1919 $updraftplus->log("Have been running very long, and it seems the database went away; scheduling a resumption and terminating for now");
1920 UpdraftPlus_Job_Scheduler::record_still_alive();
1921 die;
1922 }
1923 } else {
1924 $updraftplus->log_remove_warning('optstablenotfound');
1925 }
1926 }
1927
1928 if ($updraftplus->error_count() > $errors_at_all_tables_start) {
1929 $updraftplus->log('Errors occurred whilst backing up the tables; will cease and wait for resumption');
1930 die;
1931 }
1932
1933 // Race detection - with zip files now being resumable, these can more easily occur, with two running side-by-side
1934 $backup_final_file_name = $backup_file_base.'-db'.$this->whichdb_suffix.'.gz';
1935 $time_now = time();
1936 $time_mod = (int) @filemtime($backup_final_file_name);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
1937 if (file_exists($backup_final_file_name) && $time_mod>100 && ($time_now-$time_mod)<30) {
1938 UpdraftPlus_Job_Scheduler::terminate_due_to_activity($backup_final_file_name, $time_now, $time_mod);
1939 }
1940
1941 if (file_exists($backup_final_file_name)) {
1942 $updraftplus->log("The final database file ($backup_final_file_name) exists, but was not modified within the last 30 seconds (time_mod=$time_mod, time_now=$time_now, diff=".($time_now-$time_mod)."). Thus we assume that another UpdraftPlus terminated; thus we will continue.");
1943 }
1944
1945 // Finally, stitch the files together
1946 if (!function_exists('gzopen')) {
1947 $updraftplus->log("PHP function is disabled; abort expected: gzopen()");
1948 }
1949
1950 $decompress_mode = (defined('UPDRAFTPLUS_DB_STICH_DECOMPRESS') && UPDRAFTPLUS_DB_STICH_DECOMPRESS);
1951
1952 if (false === $this->backup_db_open($backup_final_file_name, true)) return false;
1953
1954 $this->backup_db_header();
1955
1956 // Re-open in plain binary append mode
1957 if (!$decompress_mode) {
1958 $this->backup_db_close();
1959 if (false === $this->backup_db_open($backup_final_file_name, false, true)) return false;
1960 }
1961
1962 // We delay the unlinking because if two runs go concurrently and fail to detect each other (should not happen, but there's no harm in assuming the detection failed) then that would lead to files missing from the db dump
1963 $unlink_files = array();
1964
1965 $sind = 1;
1966
1967 // Happily they have the same syntax (as far as we need it)
1968 // Concatenating gz files produces a valid gz file. So, decompressing is not necessary. We retain the possibility for debugging and the possibility of broken implementations.
1969 $open_function = $decompress_mode ? 'gzopen' : 'fopen';
1970 $fgets_function = $decompress_mode ? 'gzgets' : 'fgets';
1971 $close_function = $decompress_mode ? 'gzclose' : 'fclose';
1972
1973 foreach ($stitch_files as $table => $table_stitch_files) {
1974 ksort($table_stitch_files);
1975 foreach ($table_stitch_files as $table_file) {
1976 $updraftplus->log("{$table_file} ($sind/$how_many_tables/$open_function): adding to final database dump");
1977
1978 if (filesize($this->updraft_dir.'/'.$table_file) < 27 && '.gz' == substr($table_file, -3, 3)) {
1979 // It's a null gzip file. Don't waste time on gzopen/gzgets/gzclose. This micro-optimisation was added after seeing a site with >3000 files that was running out of time (it could apparently process 30 files/second)
1980 $unlink_files[] = $this->updraft_dir.'/'.$table_file;
1981 } elseif (!$handle = call_user_func($open_function, $this->updraft_dir.'/'.$table_file, 'r')) {
1982 $updraftplus->log("Error: Failed to open database file for reading: {$table_file}");
1983 $updraftplus->log(__("Failed to open database file for reading:", 'updraftplus').' '.$table_file, 'error');
1984 $errors++;
1985 } else {
1986 while ($line = call_user_func($fgets_function, $handle, 65536)) {
1987 $this->stow($line);
1988 }
1989 call_user_func($close_function, $handle);
1990 $unlink_files[] = $this->updraft_dir.'/'.$table_file;
1991 }
1992 $sind++;
1993 // Came across a database with 7600 tables... adding them all took over 500 seconds; and so when the resumption started up, no activity was detected
1994 if (0 == $sind % 100) UpdraftPlus_Job_Scheduler::something_useful_happened();
1995 }
1996 }
1997
1998 // Re-open in gz append mode
1999 if (!$decompress_mode) {
2000 $this->backup_db_close();
2001 if (false === $this->backup_db_open($backup_final_file_name, true, true)) return false;
2002 }
2003
2004 // DB triggers
2005 if ($this->wpdb_obj->get_results("SHOW TRIGGERS")) {
2006 // N.B. DELIMITER is not a valid SQL command; you cannot pass it to the server. It has to be interpreted by the interpreter - e.g. /usr/bin/mysql, or UpdraftPlus, and used to interpret what follows. The effect of this is that using it means that some SQL clients will stumble; but, on the other hand, failure to use it means that others that don't have special support for CREATE TRIGGER may stumble, because they may feed incomplete statements to the SQL server. Since /usr/bin/mysql uses it, we choose to support it too (both reading and writing).
2007 // Whatever the delimiter is set to needs to be used in the DROP TRIGGER and CREATE TRIGGER commands in this section further down.
2008 $this->stow("DELIMITER ;;\n\n");
2009 foreach ($all_tables as $ti) {
2010 $table = $ti['name'];
2011 if (!empty($this->skipped_tables)) {
2012 if ('wp' == $this->whichdb) {
2013 if (in_array($table, $this->skipped_tables['wp'])) continue;
2014 } elseif (isset($this->skipped_tables[$this->dbinfo['name']])) {
2015 if (in_array($table, $this->skipped_tables[$this->dbinfo['name']])) continue;
2016 }
2017 }
2018 $table_triggers = $this->wpdb_obj->get_results($wpdb->prepare("SHOW TRIGGERS LIKE %s", $table), ARRAY_A);
2019 if ($table_triggers) {
2020 $this->stow("\n\n# Triggers of ".UpdraftPlus_Manipulation_Functions::backquote($table)."\n\n");
2021 foreach ($table_triggers as $trigger) {
2022 $trigger_name = $trigger['Trigger'];
2023 $trigger_time = $trigger['Timing'];
2024 $trigger_event = $trigger['Event'];
2025 $trigger_statement = $trigger['Statement'];
2026 // Since trigger name can include backquotes and trigger name is typically enclosed with backquotes as well, the backquote escaping for the trigger name can be done by adding a leading backquote
2027 $quoted_escaped_trigger_name = UpdraftPlus_Manipulation_Functions::backquote(str_replace('`', '``', $trigger_name));
2028 $this->stow("DROP TRIGGER IF EXISTS $quoted_escaped_trigger_name;;\n");
2029 $trigger_query = "CREATE TRIGGER $quoted_escaped_trigger_name $trigger_time $trigger_event ON ".UpdraftPlus_Manipulation_Functions::backquote($table)." FOR EACH ROW $trigger_statement;;";
2030 $this->stow("$trigger_query\n\n");
2031 }
2032 }
2033 }
2034 $this->stow("DELIMITER ;\n\n");
2035 }
2036
2037 // DB Stored Routines
2038 $stored_routines = UpdraftPlus_Database_Utility::get_stored_routines();
2039 if (is_array($stored_routines) && !empty($stored_routines)) {
2040 $stored_routines_log = $inaccessible_routines = '';
2041 $stored_routines_delimiter_printed = false;
2042 $updraftplus->log("Dumping routines for database {$this->dbinfo['name']}");
2043 foreach ($stored_routines as $routine) {
2044 if (empty($routine['Create '.ucfirst(strtolower($routine['Type']))])) { // The value displayed for the Create Procedure or Create Function field is NULL if the WP database user has only CREATE ROUTINE, ALTER ROUTINE, and/or EXECUTE privileges. @see https://dev.mysql.com/doc/refman/8.0/en/show-create-procedure.html
2045 $inaccessible_routines = $inaccessible_routines ? $inaccessible_routines.', '.$routine['Name'] : $routine['Name'];
2046 continue;
2047 }
2048 $stored_routines_log = "Dumping routine: {$routine['Name']}";
2049 if (!$stored_routines_delimiter_printed) {
2050 $this->stow("\n\n# Dumping routines for database ".UpdraftPlus_Manipulation_Functions::backquote($this->dbinfo['name'])."\n\n");
2051 $this->stow("DELIMITER ;;\n\n");
2052 $stored_routines_delimiter_printed = true;
2053 }
2054 $routine_name = $routine['Name'];
2055 // Since routine name can include backquotes and routine name is typically enclosed with backquotes as well, the backquote escaping for the routine name can be done by adding a leading backquote
2056 $quoted_escaped_routine_name = UpdraftPlus_Manipulation_Functions::backquote(str_replace('`', '``', $routine_name));
2057 $this->stow("DROP {$routine['Type']} IF EXISTS $quoted_escaped_routine_name;;\n\n");
2058 $this->stow($routine['Create '.ucfirst(strtolower($routine['Type']))]."\n\n;;\n\n");
2059 $updraftplus->log($stored_routines_log." - (Successful)");
2060 }
2061 if ($inaccessible_routines) {
2062 $updraftplus->log(__('Dumping routines: ', 'updraftplus') . "({$inaccessible_routines})" . " - (". __('Failed', 'updraftplus') . " - " . __("Your WordPress database user doesn't have sufficient privileges to read these stored routines.", 'updraftplus') . " " .__('To be able to backup the routines, you must be the user named as the routine DEFINER(s), have the SHOW_ROUTINE privilege (for MySQL 8.0.20+ users), have the SELECT privilege at the global level, or have the CREATE ROUTINE, ALTER ROUTINE, or EXECUTE privilege granted at a scope that includes the routines.', 'updraftplus'). ")", 'warning');
2063 }
2064 if ($stored_routines_delimiter_printed) $this->stow("DELIMITER ;\n\n");
2065 } elseif (is_wp_error($stored_routines)) {
2066 $updraftplus->log($stored_routines->get_error_message());
2067 }
2068
2069 $this->stow("/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;\n");
2070 $this->stow("/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;\n");
2071 $this->stow("/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;\n");
2072 $this->stow("/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n");
2073
2074 $updraftplus->log($file_base.'-db'.$this->whichdb_suffix.'.gz: finished writing out complete database file ('.round(filesize($backup_final_file_name)/1024, 1).' KB)');
2075 if (!$this->backup_db_close()) {
2076 $updraftplus->log('An error occurred whilst closing the final database file');
2077 $updraftplus->log(__('An error occurred whilst closing the final database file', 'updraftplus'), 'error');
2078 $errors++;
2079 }
2080
2081 foreach ($unlink_files as $unlink_file) {
2082 @unlink($unlink_file);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
2083 }
2084
2085 if ($errors > 0) return false;
2086
2087 // We no longer encrypt here - because the operation can take long, we made it resumable and moved it to the upload loop
2088 $updraftplus->jobdata_set('jobstatus', 'dbcreated'.$this->whichdb_suffix);
2089
2090 $checksums = $updraftplus->which_checksums();
2091
2092 $checksum_description = '';
2093
2094 foreach ($checksums as $checksum) {
2095
2096 $cksum = hash_file($checksum, $backup_final_file_name);
2097 $updraftplus->jobdata_set($checksum.'-db'.(('wp' == $whichdb) ? '0' : $whichdb.'0'), $cksum);
2098 if ($checksum_description) $checksum_description .= ', ';
2099 $checksum_description .= "$checksum: $cksum";
2100
2101 }
2102
2103 $updraftplus->log("Total database tables backed up: $total_tables (".basename($backup_final_file_name).", size: ".filesize($backup_final_file_name).", $checksum_description)");
2104
2105 return basename($backup_final_file_name);
2106
2107 }
2108
2109 /**
2110 * Log the number of expected rows (both to the backup log, and database backup file)
2111 *
2112 * @param String $table - table name
2113 * @param Integer $expected_rows - number of rows
2114 * @param Boolean $via_count - if the expected number comes via a SELECT COUNT() call
2115 * @param Boolean $log_in_backup - whether to log in the backup file (not just the backup log file)
2116 */
2117 private function log_expected_rows($table, $expected_rows, $via_count = false, $log_in_backup = true) {
2118 global $updraftplus;
2119 $description = $via_count ? 'via COUNT' : 'approximate';
2120 $updraftplus->log("Table $table: Total expected rows ($description): ".$expected_rows);
2121 if ($log_in_backup) $this->stow("# Approximate rows expected in table: $expected_rows\n");
2122 if ($expected_rows > UPDRAFTPLUS_WARN_DB_ROWS) {
2123 $this->many_rows_warning = true;
2124 $updraftplus->log(
2125 sprintf(
2126 /* translators: 1: Table name, 2: Row count */
2127 __('Table %1$s has very many rows (%2$s) - we hope your web hosting company gives you enough resources to dump out that table in the backup.', 'updraftplus'),
2128 $table,
2129 $expected_rows).' '.
2130 __('If not, you will need to either remove data from this table, or contact your hosting company to request more resources.', 'updraftplus'),
2131 'warning',
2132 'manyrows_'.$this->whichdb_suffix.$table);
2133 }
2134 }
2135
2136 /**
2137 * This function will return a SQL WHERE clause to exclude updraft jobdata
2138 *
2139 * @param array $where - an array of where clauses to add to
2140 * @param string $table - the table we want to add a where clause for
2141 *
2142 * @return array - returns an array of where clauses for the table
2143 */
2144 public function backup_exclude_jobdata($where, $table) {
2145 // Don't include the job data for any backups - so that when the database is restored, it doesn't continue an apparently incomplete backup
2146 global $updraftplus;
2147 $table_prefix = $updraftplus->get_table_prefix(false); // or we can just use $this->table_prefix_raw ??
2148 if ('wp' == $this->whichdb && (!empty($table_prefix) && strtolower($table_prefix.'sitemeta') == strtolower($table))) {
2149 $where[] = 'meta_key NOT LIKE "updraft_jobdata_%"';
2150 } elseif ('wp' == $this->whichdb && (!empty($table_prefix) && strtolower($table_prefix.'options') == strtolower($table))) {
2151 // These might look similar, but the quotes are different
2152 if ('win' == strtolower(substr(PHP_OS, 0, 3))) {
2153 $updraft_jobdata = "'updraft_jobdata_%'";
2154 $site_transient_update = "'_site_transient_update_%'";
2155 } else {
2156 $updraft_jobdata = '"updraft_jobdata_%"';
2157 $site_transient_update = '"_site_transient_update_%"';
2158 }
2159
2160 $where[] = 'option_name NOT LIKE '.$updraft_jobdata.' AND option_name NOT LIKE '.$site_transient_update.'';
2161 }
2162
2163 return $where;
2164 }
2165
2166 /**
2167 * Produce a dump of the table using a mysqldump binary
2168 *
2169 * @param String $potsql - the path to the mysqldump binary
2170 * @param String $table_name - the name of the table being dumped
2171 *
2172 * @return Boolean - success status
2173 */
2174 private function backup_table_bindump($potsql, $table_name) {
2175
2176 $microtime = microtime(true);
2177
2178 global $updraftplus, $wpdb;
2179
2180 // Deal with Windows/old MySQL setups with erroneous table prefixes differing in case
2181 // Can't get binary mysqldump to make this transformation
2182 // $dump_as_table = ($this->duplicate_tables_exist == false && stripos($table, $this->table_prefix) === 0 && strpos($table, $this->table_prefix) !== 0) ? $this->table_prefix.substr($table, strlen($this->table_prefix)) : $table;
2183
2184 $pfile = md5(time().wp_rand()).'.tmp';
2185 file_put_contents($this->updraft_dir.'/'.$pfile, "[mysqldump]\npassword=\"".addslashes($this->dbinfo['pass'])."\"\n");
2186
2187 $where_array = apply_filters('updraftplus_backup_table_sql_where', array(), $table_name, $this);
2188 if ('win' === strtolower(substr(PHP_OS, 0, 3))) {
2189 // On Windows, the PHP escapeshellarg() replaces % char with white space, so we change the % char to [percent_sign] but change it back later after escapeshellarg finish processing it
2190 $where_array = str_replace('%', '[percent_sign]', $where_array);
2191 }
2192 $where = '';
2193
2194 if (!empty($where_array) && is_array($where_array)) {
2195 // N.B. Don't add a WHERE prefix here; most versions of mysqldump silently strip it out, but one was encountered that didn't.
2196 $first_loop = true;
2197 foreach ($where_array as $condition) {
2198 if (!$first_loop) $where .= " AND ";
2199 $where .= $condition;
2200 $first_loop = false;
2201 }
2202 }
2203
2204 // Note: escapeshellarg() adds quotes around the string
2205 if ($where) $where = "--where=".escapeshellarg($where);
2206 if ('' !== $where && 'win' === strtolower(substr(PHP_OS, 0, 3))) {
2207 // change the [percent_sign] back to % char
2208 $where = str_replace('[percent_sign]', '%', $where);
2209 }
2210
2211 if (strtolower(substr(PHP_OS, 0, 3)) == 'win') {
2212 $exec = "cd ".escapeshellarg(str_replace('/', '\\', $this->updraft_dir))." & ";
2213 } else {
2214 $exec = "cd ".escapeshellarg($this->updraft_dir)."; ";
2215 }
2216
2217 // Allow --max_allowed_packet to be configured via constant. Experience has shown some customers with complex CMS or pagebuilder setups can have very large postmeta entries.
2218 $msqld_max_allowed_packet = (defined('UPDRAFTPLUS_MYSQLDUMP_MAX_ALLOWED_PACKET') && (is_int(UPDRAFTPLUS_MYSQLDUMP_MAX_ALLOWED_PACKET) || is_string(UPDRAFTPLUS_MYSQLDUMP_MAX_ALLOWED_PACKET))) ? UPDRAFTPLUS_MYSQLDUMP_MAX_ALLOWED_PACKET : '12M';
2219
2220 $exec .= "$potsql --defaults-file=$pfile $where --max-allowed-packet=$msqld_max_allowed_packet --quote-names --add-drop-table";
2221
2222 static $mysql_version = null;
2223 if (null === $mysql_version) {
2224 $mysql_version = $wpdb->get_var('SELECT VERSION()');
2225 if ('' == $mysql_version) $mysql_version = $wpdb->db_version();
2226 }
2227 if ($mysql_version && version_compare($mysql_version, '5.1', '>=')) {
2228 $exec .= " --no-tablespaces";
2229 }
2230
2231 $exec .= " --skip-comments --skip-set-charset --allow-keywords --dump-date --extended-insert --user=".escapeshellarg($this->dbinfo['user'])." ";
2232
2233 $host = $this->dbinfo['host'];
2234
2235 if (preg_match('#^(.*):(\d+)$#', $host, $matches)) {
2236 // The escapeshellarg() on $matches[2] is only to avoid tripping static analysis tools
2237 $exec .= "--host=".escapeshellarg($matches[1])." --port=".escapeshellarg($matches[2])." ";
2238 } elseif (preg_match('#^(.*):(.*)$#', $host, $matches) && file_exists($matches[2])) {
2239 $exec .= "--host=".escapeshellarg($matches[1])." --socket=".escapeshellarg($matches[2])." ";
2240 } else {
2241 $exec .= "--host=".escapeshellarg($host)." ";
2242 }
2243
2244 $exec = apply_filters('updraftplus_mysqldump_arguments', $exec, $table_name, $potsql);
2245
2246 $exec .= $this->dbinfo['name']." ".escapeshellarg($table_name);
2247
2248 $ret = false;
2249 $any_output = false;
2250 $gtid_found = false;
2251 $writes = 0;
2252 $write_bytes = 0;
2253 $handle = (function_exists('popen') && function_exists('pclose')) ? popen($exec, 'r') : false;
2254 if ($handle) {
2255 while (!feof($handle)) {
2256 $w = fgets($handle, 1048576);
2257 if (is_string($w) && $w) {
2258
2259 if (preg_match('/^SET @@GLOBAL.GTID_PURGED/i', $w)) $gtid_found = true;
2260
2261 if ($gtid_found) {
2262 if (false !== strpos($w, ';')) $gtid_found = false;
2263 continue;
2264 }
2265
2266 $this->stow($w);
2267 $writes++;
2268 $write_bytes += strlen($w);
2269 $any_output = true;
2270 }
2271 }
2272 $ret = pclose($handle);
2273 // The manual page for pclose() claims that only -1 indicates an error, but this is untrue
2274 if (0 != $ret) {
2275 $updraftplus->log("Binary mysqldump: error (code: $ret)");
2276 // Keep counter of failures? Change value of binsqldump?
2277 $ret = false;
2278 } else {
2279 if ($any_output) {
2280 $updraftplus->log("Table $table_name: binary mysqldump finished (writes: $writes, bytes $write_bytes, return code $ret) in ".sprintf("%.02f", max(microtime(true)-$microtime, 0.00001))." seconds");
2281 $ret = true;
2282 }
2283 }
2284 } else {
2285 $updraftplus->log("Binary mysqldump error: bindump popen failed");
2286 }
2287
2288 // Clean temporary files
2289 @unlink($this->updraft_dir.'/'.$pfile);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
2290
2291 return $ret;
2292
2293 }
2294
2295 /**
2296 * Write out the initial backup information for a table to the currently open file
2297 *
2298 * @param String $table - Full name of database table to backup
2299 * @param String $dump_as_table - Table name to use when writing out
2300 * @param String $table_type - Table type - 'VIEW' is supported; otherwise it is treated as an ordinary table
2301 * @param Array $table_structure - Table structure as returned by a DESCRIBE command
2302 */
2303 private function write_table_backup_beginning($table, $dump_as_table, $table_type, $table_structure) {
2304
2305 $this->stow("\n# Delete any existing table ".UpdraftPlus_Manipulation_Functions::backquote($table)."\n\nDROP TABLE IF EXISTS " . UpdraftPlus_Manipulation_Functions::backquote($dump_as_table).";\n");
2306
2307 if ('VIEW' == $table_type) {
2308 $this->stow("DROP VIEW IF EXISTS " . UpdraftPlus_Manipulation_Functions::backquote($dump_as_table) . ";\n");
2309 }
2310
2311 $description = ('VIEW' == $table_type) ? 'view' : 'table';
2312
2313 $this->stow("\n# Table structure of $description ".UpdraftPlus_Manipulation_Functions::backquote($table)."\n\n");
2314
2315 $create_table = $this->wpdb_obj->get_results("SHOW CREATE TABLE ".UpdraftPlus_Manipulation_Functions::backquote($table), ARRAY_N);
2316 if (false === $create_table) {
2317 $this->stow("#\n# Error with SHOW CREATE TABLE for $table\n#\n");
2318 }
2319 $create_line = UpdraftPlus_Manipulation_Functions::str_lreplace('TYPE=', 'ENGINE=', $create_table[0][1]);
2320
2321 // Remove PAGE_CHECKSUM parameter from MyISAM - was internal, undocumented, later removed (so causes errors on import)
2322 if (preg_match('/ENGINE=([^\s;]+)/', $create_line, $eng_match)) {
2323 $engine = $eng_match[1];
2324 if ('myisam' == strtolower($engine)) {
2325 $create_line = preg_replace('/PAGE_CHECKSUM=\d\s?/', '', $create_line, 1);
2326 }
2327 }
2328
2329 if ($dump_as_table !== $table) $create_line = UpdraftPlus_Manipulation_Functions::str_replace_once($table, $dump_as_table, $create_line);
2330
2331 $this->stow($create_line.' ;');
2332
2333 if (false === $table_structure) {
2334 $this->stow("#\n# Error getting $description structure of $table\n#\n");
2335 }
2336
2337 // Add a comment preceding the beginning of the data
2338 $this->stow("\n\n# ".sprintf("Data contents of $description %s", UpdraftPlus_Manipulation_Functions::backquote($table))."\n\n");
2339
2340 }
2341
2342 /**
2343 * Suggest a beginning value for how many rows to fetch in each SELECT statement (before taking into account resumptions)
2344 *
2345 * @param String $table - the full table name
2346 *
2347 * @return Integer
2348 */
2349 private function get_rows_on_first_fetch($table) {
2350
2351 // In future, we could run over the table definition; if it is all non-massive defined lengths, we could base a calculation on that.
2352
2353 if ($this->table_prefix_raw.'term_relationships' == $table) {
2354 // This table is known to have very small data lengths
2355 $rows = 100000;
2356 } elseif (preg_match('/meta$/i', $table)) {
2357 // Meta-data rows tend to be short *on average*. 10MB / 4000 rows = 2.6KB/row, so this is still quite conservative.
2358 $rows = 4000;
2359 } else {
2360 // The very conservative default
2361 $rows = 1000;
2362 }
2363
2364 return $rows;
2365
2366 }
2367
2368 /**
2369 * Suggest how many rows to fetch in each SELECT statement
2370 *
2371 * @param String $table - the table being fetched
2372 * @param Boolean $allow_further_reductions - whether to enable a second level of reductions (i.e. even less rows)
2373 * @param Boolean $is_first_fetch_for_table - whether this is the first fetch on this table
2374 * @param Integer|Boolean $expected_rows - if an integer, an estimate of the number of rows
2375 * @param Boolean $expected_via_count - if $expected_rows is an integer, then this indicates whether the estimate was made via a SELECT COUNT() statement
2376 *
2377 * @return Integer
2378 */
2379 private function number_of_rows_to_fetch($table, $allow_further_reductions, $is_first_fetch_for_table, $expected_rows = false, $expected_via_count = false) {
2380
2381 global $updraftplus;
2382
2383 // This used to be fixed at 500; but we (after a long time) saw a case that looked like an out-of-memory even at this level. Now that we have implemented resumptions, the risk of timeouts is much lower (we just need to process enough rows).
2384 // October 2020: added further reductions
2385 // Listed in increasing order due to the handling below. At the end it gets quite drastic. Note, though, that currently we don't store this in the job-data.
2386 // A future improvement could, when things get drastic, grab and log data on the size of what is required, so that we can respond more dynamically. The strategy currently here will run out of road if memory falls short multiple times. See: https://stackoverflow.com/questions/4524019/how-to-get-the-byte-size-of-resultset-in-an-sql-query
2387 $fetch_rows_reductions = array(500, 250, 200, 100);
2388
2389 $default_on_first_fetch = $this->get_rows_on_first_fetch($table);
2390
2391 $known_bigger_than_table = (!is_bool($expected_rows) && $expected_rows && $expected_via_count && $default_on_first_fetch > 2 * $expected_rows);
2392
2393 if ($known_bigger_than_table) $allow_further_reductions = true;
2394
2395 if ($allow_further_reductions) {
2396 // If we're relying on LIMIT with offsets, then we have to be mindful of how that performs
2397 $fetch_rows_reductions = array_merge($fetch_rows_reductions, array(50, 20, 5));
2398 }
2399
2400 // Remove any that are far out of range
2401 if ($known_bigger_than_table) {
2402 foreach ($fetch_rows_reductions as $k => $reduce_to) {
2403 if ($reduce_to > $expected_rows * 2 && count($fetch_rows_reductions) > 2) {
2404 unset($fetch_rows_reductions[$k]);
2405 }
2406 }
2407 }
2408
2409 // If this is not the first fetch on a table, then get what was stored last time we set it (if we ever did). On the first fetch, reset back to the starting value (we presume problems are table-specific).
2410 // This means that the same value will persist whilst the table is being backed up, both during the current resumption, and subsequent ones
2411 $fetch_rows = $is_first_fetch_for_table ? $default_on_first_fetch : $updraftplus->jobdata_get('fetch_rows', $default_on_first_fetch);
2412
2413 $fetch_rows_at_start = $fetch_rows;
2414
2415 $resumptions_since_last_successful = $updraftplus->current_resumption - $updraftplus->last_successful_resumption;
2416
2417 // Do we need to reduce the number of rows we attempt to fetch?
2418 // If something useful has happened on this run, then we don't try any reductions (we save them for a resumption after one on which nothing useful happened)
2419 if ($known_bigger_than_table || (!$updraftplus->something_useful_happened && !empty($updraftplus->current_resumption) && $resumptions_since_last_successful > 1)) {
2420
2421 $break_after = $is_first_fetch_for_table ? max($resumptions_since_last_successful - 1, 1) : 1;
2422
2423 foreach ($fetch_rows_reductions as $reduce_to) {
2424 if ($fetch_rows > $reduce_to) {
2425 // Go down one level
2426 $fetch_rows = $reduce_to;
2427 $break_after--;
2428 if ($break_after < 1) break;
2429 }
2430 }
2431
2432 $log_start = $updraftplus->current_resumption ? "Last successful resumption was $resumptions_since_last_successful runs ago" : "Table is relatively small";
2433 $updraftplus->log("$log_start; fetch_rows will thus be: $fetch_rows (allow_further_reductions=$allow_further_reductions, is_first_fetch=$is_first_fetch_for_table, known_bigger_than_table=$known_bigger_than_table)");
2434 }
2435
2436 // If it has changed, then preserve it in the job for the next resumption (of this table)
2437 if ($fetch_rows_at_start !== $fetch_rows || $is_first_fetch_for_table) $updraftplus->jobdata_set('fetch_rows', $fetch_rows);
2438
2439 return apply_filters('updraftplus_number_of_rows_to_fetch', $fetch_rows, $table, $allow_further_reductions, $is_first_fetch_for_table, $expected_rows, $expected_via_count);
2440
2441 }
2442
2443 /**
2444 * Return a list of primary keys (N.B. the method should not be called unless the caller already knows that the table has a single/simple primary key) for rows that have "over-sized" data.
2445 * Currently this only examines the "posts" table and any other table with a longtext type, which are the primary causes of problems. If others are revealed in future, it can be generalised (e.g. examine the whole definition/all cells).
2446 *
2447 * @param String $table - the full table name
2448 * @param Array $structure - the table structure, as from WPDB::get_results("DESCRIBE ...");
2449 * @param String $primary_key - the primary key to use; required if $structure is set (slightly redundant, since it can be derived from structure)
2450 *
2451 * @return Array - list of IDs
2452 */
2453 private function get_oversized_rows($table, $structure = array(), $primary_key = '') {
2454
2455 if ($this->table_prefix_raw.'posts' != $table) {
2456 if (empty($structure) || '' === $primary_key) return array();
2457 foreach ($structure as $item) {
2458 if ('' !== $item->Field && 'longtext' === $item->Type) {
2459 $use_field = $item->Field;
2460 }
2461 }
2462 } else {
2463 $primary_key = 'id';
2464 $use_field = 'post_content';
2465 }
2466
2467 if (!isset($use_field)) return array();
2468
2469 global $updraftplus;
2470
2471 // Look for the jobdata_delete() call elsewhere in this class - the key name needs to match
2472 $jobdata_key = 'oversized_rows_'.$table;
2473
2474 $oversized_list = $updraftplus->jobdata_get($jobdata_key);
2475
2476 if (is_array($oversized_list)) return $oversized_list;
2477
2478 $oversized_list = array();
2479
2480 // Allow over-ride via a constant
2481 $oversized_row_size = defined('UPDRAFTPLUS_OVERSIZED_ROW_SIZE') ? UPDRAFTPLUS_OVERSIZED_ROW_SIZE : 2048576;
2482
2483 $sql = $this->wpdb_obj->prepare("SELECT ".UpdraftPlus_Manipulation_Functions::backquote($primary_key)." FROM ".UpdraftPlus_Manipulation_Functions::backquote($table)." WHERE LENGTH(".UpdraftPlus_Manipulation_Functions::backquote($use_field).") > %d ORDER BY ".UpdraftPlus_Manipulation_Functions::backquote($primary_key)." ASC", $oversized_row_size);
2484
2485 $oversized_rows = $this->wpdb_obj->get_col($sql);
2486
2487 // Upon an error, just return an empty list
2488 if (!is_array($oversized_rows)) return array();
2489
2490 $updraftplus->jobdata_set($jobdata_key, $oversized_rows);
2491
2492 return $oversized_rows;
2493
2494 }
2495
2496 /**
2497 * Original version taken partially from phpMyAdmin and partially from Alain Wolf, Zurich - Switzerland to use the WordPress $wpdb object
2498 * Website: http://restkultur.ch/personal/wolf/scripts/db_backup/
2499 * Modified by Scott Merrill (http://www.skippy.net/)
2500 * Subsequently heavily improved and modified
2501 *
2502 * This method should be called in a loop for a complete table backup (see the information for the returned parameter). The method may implement whatever strategy it likes for deciding when to return (the assumption is that when it does return with some results, the caller should register that something useful happened).
2503 *
2504 * @param String $table - Full name of database table to backup
2505 * @param String $table_type - Table type - 'VIEW' is supported; otherwise it is treated as an ordinary table
2506 * @param Integer|Boolean $start_record - Specify the starting record, or true to start at the beginning. Our internal page size is fixed at 1000 (though within that we might actually query in smaller batches).
2507 * @param Boolean $can_use_primary_key - Whether it is allowed to perform quicker SELECTS based on the primary key. The intended use case for false is to support backups running during a version upgrade. N.B. This "can" is not absolute; there may be other constraints dealt with within this method.
2508 *
2509 * @return Integer|Array|WP_Error - a WP_Error to indicate an error; an array indicates that it finished (if it includes 'next_record' that means it finished via producing something); an integer to indicate the next page the case that there are more to do.
2510 */
2511 private function backup_table($table, $table_type = 'BASE TABLE', $start_record = true, $can_use_primary_key = true) {
2512 $process_pages = 100;
2513
2514 // Preserve the passed-in value
2515 $original_start_record = $start_record;
2516
2517 global $updraftplus;
2518
2519 $microtime = microtime(true);
2520 $total_rows = 0;
2521
2522 // Deal with Windows/old MySQL setups with erroneous table prefixes differing in case
2523 $dump_as_table = (false == $this->duplicate_tables_exist && 0 === stripos($table, $this->table_prefix) && 0 !== strpos($table, $this->table_prefix)) ? $this->table_prefix.substr($table, strlen($this->table_prefix)) : $table;
2524
2525 $table_structure = $this->wpdb_obj->get_results("DESCRIBE ".UpdraftPlus_Manipulation_Functions::backquote($table));
2526 if (!$table_structure) {
2527 // $updraftplus->log(__('Error getting table details', 'updraftplus') . ": $table", 'error');
2528 $error_message = '';
2529 if ($this->wpdb_obj->last_error) $error_message .= ' ('.$this->wpdb_obj->last_error.')';
2530 return new WP_Error('table_details_error', $error_message);
2531 }
2532
2533 // If at the beginning of the dump for a table, then add the DROP and CREATE statements
2534 if (true === $start_record) {
2535 $this->write_table_backup_beginning($table, $dump_as_table, $table_type, $table_structure);
2536 }
2537
2538 // Some tables have optional data, and should be skipped if they do not work
2539 $table_sans_prefix = substr($table, strlen($this->table_prefix_raw));
2540 $data_optional_tables = ('wp' == $this->whichdb) ? apply_filters('updraftplus_data_optional_tables', explode(',', UPDRAFTPLUS_DATA_OPTIONAL_TABLES)) : array();
2541 if (in_array($table_sans_prefix, $data_optional_tables)) {
2542 if (!$updraftplus->something_useful_happened && !empty($updraftplus->current_resumption) && ($updraftplus->current_resumption - $updraftplus->last_successful_resumption > 2)) {
2543 $updraftplus->log("Table $table: Data skipped (previous attempts failed, and table is marked as non-essential)");
2544 return array();
2545 }
2546 }
2547
2548 $table_data = array();
2549 if ('VIEW' != $table_type) {
2550 $select_selected_fields = $insert_selected_fields = array();
2551 $defs = array();
2552 $integer_fields = array();
2553 $binary_fields = array();
2554 $bit_fields = array();
2555 $bit_field_exists = false;
2556 $invisible_field_exists = false;
2557
2558 // false means "not yet set"; a string means what it was set to; null means that there are multiple (and so not useful to us). If it is not a string, then $primary_key_type is invalid and should not be used.
2559 $primary_key = false;
2560 $primary_key_type = false;
2561
2562 // $table_structure was from "DESCRIBE $table"
2563 foreach ($table_structure as $struct) {
2564
2565 if (isset($struct->Key) && 'PRI' == $struct->Key && '' != $struct->Field) {
2566 $primary_key = (false === $primary_key) ? $struct->Field : null;
2567 $primary_key_type = $struct->Type;
2568 }
2569
2570 if ((0 === strpos($struct->Type, 'tinyint')) || (0 === strpos(strtolower($struct->Type), 'smallint'))
2571 || (0 === strpos(strtolower($struct->Type), 'mediumint')) || (0 === strpos(strtolower($struct->Type), 'int')) || (0 === strpos(strtolower($struct->Type), 'bigint'))
2572 ) {
2573 $defs[strtolower($struct->Field)] = (null === $struct->Default) ? 'NULL' : $struct->Default;
2574 $integer_fields[strtolower($struct->Field)] = true;
2575 }
2576
2577 if ((0 === strpos(strtolower($struct->Type), 'binary')) || (0 === strpos(strtolower($struct->Type), 'varbinary')) || (0 === strpos(strtolower($struct->Type), 'tinyblob')) || (0 === strpos(strtolower($struct->Type), 'mediumblob')) || (0 === strpos(strtolower($struct->Type), 'blob')) || (0 === strpos(strtolower($struct->Type), 'longblob'))) {
2578 $binary_fields[strtolower($struct->Field)] = true;
2579 }
2580
2581 $insert_selected_fields[] = UpdraftPlus_Manipulation_Functions::backquote(str_replace('`', '``', $struct->Field));
2582
2583 if (preg_match('/^bit(?:\(([0-9]+)\))?$/i', trim($struct->Type), $matches)) {
2584 if (!$bit_field_exists) $bit_field_exists = true;
2585 $bit_fields[strtolower($struct->Field)] = !empty($matches[1]) ? max(1, (int) $matches[1]) : 1;
2586 // the reason why if bit fields are found then the fields need to be cast into binary type is that if mysqli_query function is being used, mysql will convert the bit field value to a decimal number and represent it in a string format whereas, if mysql_query function is being used, mysql will not convert it to a decimal number but instead will keep it retained as it is
2587 $struct->Field = "CAST(".UpdraftPlus_Manipulation_Functions::backquote(str_replace('`', '``', $struct->Field))." AS BINARY) AS ".UpdraftPlus_Manipulation_Functions::backquote(str_replace('`', '``', $struct->Field));
2588 $select_selected_fields[] = $struct->Field;
2589 } else {
2590 $select_selected_fields[] = UpdraftPlus_Manipulation_Functions::backquote(str_replace('`', '``', $struct->Field));
2591 }
2592
2593 if (isset($struct->Extra) && preg_match('/\binvisible\b/i', $struct->Extra)) $invisible_field_exists = true;
2594 }
2595
2596 $expected_via_count = false;
2597
2598 // N.B. At this stage this is for optimisation, mainly targets what is used on the core WP tables (bigint(20)); a value can be relied upon, but false is not definitive. N.B. https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/numeric-type-syntax.html (retrieved Aug 2021): "As of MySQL 8.0.17, the display width attribute is deprecated for integer data types; you should expect support for it to be removed in a future version of MySQL." MySQL 8.0.20 is not returning it.
2599 $use_primary_key = false;
2600 if ($can_use_primary_key && is_string($primary_key) && preg_match('#^(small|medium|big)?int(\(| |$)#i', $primary_key_type)) {
2601 $use_primary_key = true;
2602
2603 // We don't bother re-counting if it's likely to be so large that we're not going to do anything with the result
2604 if (is_bool($this->expected_rows) || $this->expected_rows < 1000) {
2605 $expected_rows = $this->wpdb_obj->get_var('SELECT COUNT('.UpdraftPlus_Manipulation_Functions::backquote($primary_key).') FROM '.UpdraftPlus_Manipulation_Functions::backquote($table));
2606 if (!is_bool($expected_rows)) {
2607 $this->expected_rows = $expected_rows;
2608 $expected_via_count = true;
2609 }
2610 }
2611
2612 $oversized_rows = $this->get_oversized_rows($table, $table_structure, $primary_key);
2613
2614 if (preg_match('# unsigned$#i', $primary_key_type)) {
2615 if (true === $start_record) $start_record = -1;
2616 } else {
2617 if (true === $start_record) {
2618 $min_value = $this->wpdb_obj->get_var('SELECT MIN('.UpdraftPlus_Manipulation_Functions::backquote($primary_key).') FROM '.UpdraftPlus_Manipulation_Functions::backquote($table));
2619 $start_record = (is_numeric($min_value) && $min_value) ? (int) $min_value - 1 : -1;
2620 }
2621 }
2622 }
2623
2624 if (!is_bool($this->expected_rows)) {
2625 $log_expected_records = (true === $original_start_record);
2626 $this->log_expected_rows($table, $this->expected_rows, $expected_via_count, $log_expected_records);
2627 }
2628
2629 $search = array("\x00", "\x0a", "\x0d", "\x1a");
2630 $replace = array('\0', '\n', '\r', '\Z');
2631
2632 $where_array = apply_filters('updraftplus_backup_table_sql_where', array(), $table, $this);
2633 $where = '';
2634 if (!empty($where_array) && is_array($where_array)) {
2635 $where = 'WHERE '.implode(' AND ', $where_array);
2636 }
2637
2638 // Experimentation here shows that on large tables (we tested with 180,000 rows) on MyISAM, 1000 makes the table dump out 3x faster than the previous value of 100. After that, the benefit diminishes (increasing to 4000 only saved another 12%)
2639
2640 $fetch_rows = $this->number_of_rows_to_fetch($table, $use_primary_key || $start_record < 500000, true === $original_start_record, $this->expected_rows, $expected_via_count);
2641
2642 if (!is_bool($this->expected_rows)) $this->expected_rows = true;
2643
2644 $original_fetch_rows = $fetch_rows;
2645
2646 $select_selected_fields = $bit_field_exists || $invisible_field_exists ? implode(', ', $select_selected_fields) : '*';
2647
2648 $enough_for_now = false;
2649
2650 $began_writing_at = time();
2651
2652 $enough_data_after = 104857600;
2653 $enough_time_after = ($fetch_rows > 250) ? 15 : 9;
2654
2655 // Loop which retrieves data
2656 do {
2657
2658 if (function_exists('set_time_limit')) @set_time_limit(UPDRAFTPLUS_SET_TIME_LIMIT);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
2659
2660 // Reset back to that which has constructed before the loop began
2661 $final_where = $where;
2662
2663 if ($use_primary_key) {
2664
2665 // The point of this is to leverage the indexing on the private key to make the SELECT much faster than index-less paging
2666 $final_where = $where . ($where ? ' AND ' : 'WHERE ');
2667
2668 // If it's -1, then we avoid mentioning a negative value, as the value may be unsigned
2669 $final_where .= UpdraftPlus_Manipulation_Functions::backquote($primary_key).((-1 === $start_record) ? ' >= 0' : " > $start_record");
2670
2671 $oversized_last_row_id = false;
2672 // Remove ones we've gone past
2673 foreach ($oversized_rows as $k => $row_id) {
2674 if ($start_record >= $row_id) {
2675 unset($oversized_rows[$k]);
2676 } else {
2677 $oversized_last_row_id = $row_id;
2678 // At this point we are only willing to fetch a single over-sized row. If this ever changes, we'll need to also keep track of their length.
2679 break;
2680 }
2681 }
2682 // Number the keys again from zero
2683 $oversized_rows = array_values($oversized_rows);
2684
2685 if ($oversized_last_row_id) {
2686 $final_where .= " AND ". UpdraftPlus_Manipulation_Functions::backquote($primary_key)." <= $oversized_last_row_id";
2687 }
2688
2689 $limit_statement = sprintf('LIMIT %d', $fetch_rows);
2690
2691 $order_by = 'ORDER BY '.UpdraftPlus_Manipulation_Functions::backquote($primary_key).' ASC';
2692
2693 } else {
2694 $order_by = '';
2695 if (true === $start_record) $start_record = 0;
2696 $limit_statement = sprintf('LIMIT %d, %d', $start_record, $fetch_rows);
2697 }
2698
2699 // $this->wpdb_obj->prepare() not needed (will throw a notice) as there are no parameters (all parts are already sanitised or cast to known-safe types if not sanitised here)
2700 $select_sql = "SELECT $select_selected_fields FROM ".UpdraftPlus_Manipulation_Functions::backquote($table)." $final_where $order_by $limit_statement";
2701
2702 if (defined('UPDRAFTPLUS_LOG_BACKUP_SELECTS') && UPDRAFTPLUS_LOG_BACKUP_SELECTS) $updraftplus->log($select_sql);
2703
2704 // Allow the data to be filtered (e.g. anonymisation)
2705 $table_data = apply_filters('updraftplus_backup_table_results', $this->wpdb_obj->get_results($select_sql, ARRAY_A), $table, $this->table_prefix, $this->whichdb);
2706
2707 if (null === $table_data) {
2708 $updraftplus->log("Database fetch error (null returned) when running: $select_sql");
2709 }
2710
2711 $oversized_changes = false;
2712
2713 if (!$table_data) {
2714 // Nothing was found - not even the expected over-sized row; this means it was deleted - so don't try to use a limitation based on it again, or we may get an infinite loop.
2715 if (isset($oversized_last_row_id) && false !== $oversized_last_row_id) {
2716 if (false !== ($key = array_search($oversized_last_row_id, $oversized_rows))) {
2717 unset($oversized_rows[$key]);
2718 $oversized_changes = true;
2719 }
2720 }
2721 if ($oversized_changes) $updraftplus->jobdata_set('oversized_rows_'.$table, $oversized_rows);
2722 continue;
2723 }
2724 $insert_columns_sql = $invisible_field_exists ? ' (' . implode(', ', $insert_selected_fields) . ')' : '';
2725 $entries = 'INSERT INTO '.UpdraftPlus_Manipulation_Functions::backquote($dump_as_table).$insert_columns_sql.' VALUES ';
2726
2727 // \x08\\x09, not required
2728
2729 $this_entry = '';
2730 foreach ($table_data as $row) {
2731 $total_rows++;
2732 if ($this_entry) $this_entry .= ",\n ";
2733 $this_entry .= '(';
2734 $key_count = 0;
2735 foreach ($row as $key => $value) {
2736
2737 if ($key_count) $this_entry .= ', ';
2738 $key_count++;
2739
2740 if ($use_primary_key && strtolower($primary_key) == strtolower($key) && $value > $start_record) {
2741 $start_record = $value;
2742 foreach ($oversized_rows as $k => $row_id) {
2743 if ($start_record >= $row_id) {
2744 unset($oversized_rows[$k]);
2745 } else {
2746 break;
2747 }
2748 }
2749 }
2750
2751 if (isset($integer_fields[strtolower($key)])) {
2752 // make sure there are no blank spots in the insert syntax,
2753 // yet try to avoid quotation marks around integers
2754 $value = (null === $value || '' === $value) ? $defs[strtolower($key)] : $value;
2755 $value = ('' === $value) ? "''" : $value;
2756 $this_entry .= $value;
2757 } elseif (isset($binary_fields[strtolower($key)])) {
2758 if (null === $value) {
2759 $this_entry .= 'NULL';
2760 } elseif ('' === $value) {
2761 $this_entry .= "''";
2762 } else {
2763 $this_entry .= "0x" . bin2hex(str_repeat("0", floor(strspn($value, "0") / 4)).$value);
2764 }
2765 } elseif (isset($bit_fields[strtolower($key)])) {
2766 if (null === $value) {
2767 $this_entry .= 'NULL';
2768 } else {
2769 mbstring_binary_safe_encoding();
2770 $val_len = is_string($value) ? strlen($value) : 0;
2771 reset_mbstring_encoding();
2772 $hex = '';
2773 for ($i=0; $i<$val_len; $i++) {
2774 $hex .= sprintf('%02X', ord($value[$i]));
2775 }
2776 $this_entry .= "b'".str_pad($this->hex2bin($hex), $bit_fields[strtolower($key)], '0', STR_PAD_LEFT)."'";
2777 }
2778 } else {
2779 $this_entry .= (null === $value) ? 'NULL' : "'" . str_replace($search, $replace, str_replace('\'', '\\\'', str_replace('\\', '\\\\', $value))) . "'";
2780 }
2781 }
2782 $this_entry .= ')';
2783
2784 // Flush every 512KB
2785 if (strlen($this_entry) > 524288) {
2786 $this_entry .= ';';
2787 if (strlen($this_entry) > 10485760) {
2788 // This is an attempt to prevent avoidable duplication of long strings in-memory, at the cost of one extra write
2789 $this->stow(" \n".$entries);
2790 $this->stow($this_entry);
2791 } else {
2792 $this->stow(" \n".$entries.$this_entry);
2793 }
2794 $this_entry = '';
2795 // Potentially indicate that enough has been done to loop
2796 if ($this->db_current_raw_bytes > $enough_data_after || time() - $began_writing_at > $enough_time_after) {
2797 $enough_for_now = true;
2798 }
2799 }
2800
2801 }
2802
2803 if ($this_entry) {
2804 $this_entry .= ';';
2805 if (strlen($this_entry) > 10485760) {
2806 // This is an attempt to prevent avoidable duplication of long strings in-memory, at the cost of one extra write
2807 $this->stow(" \n".$entries);
2808 $this->stow($this_entry);
2809 } else {
2810 $this->stow(" \n".$entries.$this_entry);
2811 }
2812 }
2813
2814 // Increment this before any potential changes to $fetch_rows
2815 if (!$use_primary_key) {
2816 $start_record += $fetch_rows;
2817 }
2818
2819 // Potentially fetch more rows at once, if performance has been good on a sufficient number of rows
2820 // However - testing indicates that this makes very little difference to overall performance; MySQL's performance scales linearly with the number of rows requested. So optimisations here are unlikely to be worthwhile. (Probably better to remove LIMIT and ORDER BY entirely on tables that look small enough to fit into memory in one go)
2821 if (!$enough_for_now && $total_rows > 0 && $fetch_rows >= $original_fetch_rows && $fetch_rows < $original_fetch_rows * 8 && $this->db_current_raw_bytes > 10000 && $total_rows > 5000) {
2822 $bytes_per_row = $this->db_current_raw_bytes / $total_rows;
2823 // Increase the numbers of rows fetched if we still expect it to be less than 5MB, and the rate is acceptable
2824 if (2 * $fetch_rows * $bytes_per_row < 5242880) {
2825 // N.B. This does not persist across resumptions
2826 $fetch_rows = $fetch_rows * 2;
2827 $process_pages = $process_pages / 2;
2828 }
2829 }
2830
2831 if ($process_pages > 0) $process_pages--;
2832
2833 // The condition involving count($oversized_rows) is for when rows that were in oversized_rows got deleted before being fetched; the "ID < (row)" condition could result in no data being returned, even though the table isn't finished
2834 } while (!$enough_for_now && (count($table_data) > 0 || (isset($oversized_rows) && count($oversized_rows) > 0)) && (-1 == $process_pages || $process_pages > 0));
2835 }
2836
2837 $fetch_time = max(microtime(true)-$microtime, 0.00001);
2838
2839 $updraftplus->log("Table $table: Rows added in this batch (next record: $start_record): $total_rows (uncompressed bytes in this segment=".$this->db_current_raw_bytes.") in ".sprintf('%.02f', $fetch_time).' seconds');
2840
2841 // If all data has been fetched, then write out the closing comment
2842 if (-1 == $process_pages || 0 == count($table_data)) {
2843 $this->stow("\n# End of data contents of table ".UpdraftPlus_Manipulation_Functions::backquote($table)."\n\n");
2844 // Keep the keyname here in sync with what is in self::get_oversized_rows()
2845 $updraftplus->jobdata_delete('oversized_rows_'.$table);
2846 return is_numeric($start_record) ? array('next_record' => (int) $start_record) : array();
2847 }
2848
2849 return is_numeric($start_record) ? (int) $start_record : $start_record;
2850
2851 }
2852
2853 /**
2854 * Convert hexadecimal (base16) number into binary (base2) and no need to worry about the platform-dependent of 32bit/64bit size limitation
2855 *
2856 * @param String $hex Hexadecimal number
2857 * @return String a base2 format of the given hexadecimal number
2858 */
2859 private function hex2bin($hex) {
2860 $table = array(
2861 '0' => '0000',
2862 '1' => '0001',
2863 '2' => '0010',
2864 '3' => '0011',
2865 '4' => '0100',
2866 '5' => '0101',
2867 '6' => '0110',
2868 '7' => '0111',
2869 '8' => '1000',
2870 '9' => '1001',
2871 'a' => '1010',
2872 'b' => '1011',
2873 'c' => '1100',
2874 'd' => '1101',
2875 'e' => '1110',
2876 'f' => '1111'
2877 );
2878 $bin = '';
2879
2880 if (!preg_match('/^[0-9a-f]+$/i', $hex)) return '';
2881
2882 for ($i = 0; $i < strlen($hex); $i++) {
2883 $bin .= $table[strtolower(substr($hex, $i, 1))];
2884 }
2885
2886 return $bin;
2887 }
2888
2889 /**
2890 * Encrypts the file if the option is set; returns the basename of the file (according to whether it was encrypted or not)
2891 *
2892 * @param String $file - file to encrypt
2893 *
2894 * @return array
2895 */
2896 public function encrypt_file($file) {
2897 global $updraftplus;
2898 $encryption = $updraftplus->get_job_option('updraft_encryptionphrase');
2899 if (!is_string($encryption)) $encryption = '';
2900 if (strlen($encryption) > 0) {
2901 $updraftplus->log("Attempting to encrypt backup file");
2902 try {
2903 $result = apply_filters('updraft_encrypt_file', null, $file, $encryption, $this->whichdb, $this->whichdb_suffix);
2904 } catch (Exception $e) {
2905 $log_message = 'Exception ('.get_class($e).') occurred during encryption: '.$e->getMessage().' (Code: '.$e->getCode().', line '.$e->getLine().' in '.$e->getFile().')';
2906 UpdraftPlus_Manipulation_Functions::error_log($log_message);
2907 // @codingStandardsIgnoreLine
2908 $log_message .= ' Backtrace: '.str_replace(array(ABSPATH, "\n"), array('', ', '), $e->getTraceAsString());
2909 $updraftplus->log($log_message);
2910 /* translators: 1: Exception class, 2: Exception message */
2911 $updraftplus->log(sprintf(__('A PHP exception (%1$s) has occurred: %2$s', 'updraftplus'), get_class($e), $e->getMessage()), 'error');
2912 die();
2913 } catch (Error $e) {// phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound -- This Error class will only get triggered during runtime but we don't explicitly throw this class in our code; so we only catch it when PHP throws it.
2914 $log_message = 'PHP Fatal error ('.get_class($e).') has occurred during encryption. Error Message: '.$e->getMessage().' (Code: '.$e->getCode().', line '.$e->getLine().' in '.$e->getFile().')';
2915 UpdraftPlus_Manipulation_Functions::error_log($log_message);
2916 // @codingStandardsIgnoreLine
2917 $log_message .= ' Backtrace: '.str_replace(array(ABSPATH, "\n"), array('', ', '), $e->getTraceAsString());
2918 $updraftplus->log($log_message);
2919 /* translators: 1: Exception class, 2: Exception message */
2920 $updraftplus->log(sprintf(__('A PHP fatal error (%1$s) has occurred: %2$s', 'updraftplus'), get_class($e), $e->getMessage()), 'error');
2921 die();
2922 }
2923 if (null === $result) return basename($file);
2924 return $result;
2925 } else {
2926 return basename($file);
2927 }
2928 }
2929
2930 /**
2931 * Close the database file currently being written
2932 *
2933 * @return Boolean
2934 */
2935 private function backup_db_close() {
2936 return $this->dbhandle_isgz ? gzclose($this->dbhandle) : fclose($this->dbhandle);
2937 }
2938
2939 /**
2940 * Open a file, store its file handle, and reset the class variable db_current_raw_bytes back to zero.
2941 *
2942 * @param String $file Full path to the file to open
2943 * @param Boolean $allow_gz Use gzopen() if available, instead of fopen()
2944 * @param Boolean $append Use append mode for writing
2945 *
2946 * @return Resource|Boolean - the opened file handle, or false for an error
2947 */
2948 public function backup_db_open($file, $allow_gz = true, $append = false) {
2949 $mode = $append ? 'ab' : 'w';
2950 if ($allow_gz && function_exists('gzopen')) {
2951 $this->dbhandle = gzopen($file, $mode);
2952 $this->dbhandle_isgz = true;
2953 } else {
2954 $this->dbhandle = fopen($file, $mode);
2955 $this->dbhandle_isgz = false;
2956 }
2957 if (false === $this->dbhandle) {
2958 global $updraftplus;
2959 $updraftplus->log("ERROR: $file: Could not open the backup file for writing (mode: $mode)");
2960 $updraftplus->log($file.": ".__("Could not open the backup file for writing", 'updraftplus'), 'error');
2961 }
2962 $this->db_current_raw_bytes = 0;
2963 return $this->dbhandle;
2964 }
2965
2966 /**
2967 * Adds a line to the database backup
2968 *
2969 * @param String $write_line - the line to write
2970 *
2971 * @return Integer|Boolean - the number of octets written, or false for a failure (as returned by gzwrite() / fwrite)
2972 */
2973 private function stow($write_line) {
2974
2975 if ('' === $write_line) return 0;
2976
2977 $write_function = $this->dbhandle_isgz ? 'gzwrite' : 'fwrite';
2978
2979 if (false == ($ret = call_user_func($write_function, $this->dbhandle, $write_line))) {
2980 $this->log_with_db_occasionally("There was an error writing a line to the backup file: $write_line");
2981 }
2982
2983 $this->db_current_raw_bytes += strlen($write_line);
2984
2985 return $ret;
2986 }
2987
2988 /**
2989 * Stow the database backup header
2990 */
2991 private function backup_db_header() {
2992
2993 global $updraftplus;
2994 $wp_version = $updraftplus->get_wordpress_version();
2995 $mysql_version = $this->wpdb_obj->get_var('SELECT VERSION()');
2996 if ('' == $mysql_version) $mysql_version = $this->wpdb_obj->db_version();
2997 $server_software = UpdraftPlus_Manipulation_Functions::fetch_superglobal('server', 'SERVER_SOFTWARE', '');
2998
2999 if ('wp' == $this->whichdb) {
3000 $wp_upload_dir = wp_upload_dir();
3001 $this->stow("# WordPress MySQL database backup\n");
3002 $this->stow("# Created by UpdraftPlus version ".$updraftplus->version." (https://updraftplus.com)\n");
3003 $this->stow("# WordPress Version: $wp_version, running on PHP ".phpversion()." (".$server_software."), MySQL $mysql_version\n");
3004 $this->stow("# Backup of: ".untrailingslashit(site_url())."\n");
3005 $this->stow("# Home URL: ".untrailingslashit(home_url())."\n");
3006 $this->stow("# Content URL: ".untrailingslashit(content_url())."\n");
3007 $this->stow("# Uploads URL: ".untrailingslashit($wp_upload_dir['baseurl'])."\n");
3008 $this->stow("# Table prefix: ".$this->table_prefix_raw."\n");
3009 $this->stow("# Filtered table prefix: ".$this->table_prefix."\n");
3010 $this->stow("# ABSPATH: ".trailingslashit(ABSPATH)."\n");
3011 $this->stow("# UpdraftPlus plugin slug: ".UPDRAFTPLUS_PLUGIN_SLUG."\n");
3012 $this->stow("# Site info: multisite=".(is_multisite() ? '1' : '0')."\n");
3013
3014 if (is_multisite()) {
3015 // we're going to use the fallback function wp_get_sites (for older version).
3016 if (function_exists('get_sites') && class_exists('WP_Site_Query')) {
3017 $network_sites = get_sites();
3018 } elseif (function_exists('wp_get_sites')) {
3019 $network_sites = wp_get_sites();// phpcs:ignore WordPress.WP.DeprecatedFunctions.wp_get_sitesFound -- This function was only intended for backward compatibility with versions below 4.6.
3020 }
3021
3022 if ($network_sites) {
3023 $multisite_data = array();
3024 foreach ($network_sites as $site) {
3025 // get_sites() gives $site in object form whereas wp_get_sites() gives sites in array form.
3026 $site = (array) $site;
3027 $blog_id = intval($site['blog_id']);
3028
3029 $switched = switch_to_blog($blog_id);
3030 $wp_upload_dir = wp_upload_dir();
3031 $multisite_data[] = array(
3032 'blog_id' => $blog_id,
3033 'domain' => $site['domain'],
3034 'path' => $site['path'],
3035 'siteurl' => untrailingslashit(site_url()),
3036 'home' => untrailingslashit(home_url()),
3037 'uploads_url' => $wp_upload_dir['baseurl'],
3038 );
3039 if ($switched) restore_current_blog();
3040 }
3041 $this->stow("# Site info: multisite_data=".json_encode($multisite_data)."\n");
3042 if (function_exists('get_main_site_id')) $this->stow("# Site info: multisite_main_site_id=".intval(get_main_site_id())."\n");
3043 }
3044 }
3045 $this->stow("# Site info: sql_mode=".$this->wpdb_obj->get_var('SELECT @@SESSION.sql_mode')."\n");
3046
3047 add_filter('updraftplus_backup_db_header_site_info', array($this, 'backup_db_header_site_info_woocommerce'));
3048
3049 // This filter allows a user to add extra information about site info.
3050 if (array() !== ($site_info = apply_filters('updraftplus_backup_db_header_site_info', array()))) {
3051 foreach ($site_info as $info) {
3052 $this->stow("# Site info: ".$info."\n");
3053 }
3054 }
3055
3056 $this->stow("# Site info: end\n");
3057 } else {
3058 $this->stow("# MySQL database backup (supplementary database ".$this->whichdb.")\n");
3059 $this->stow("# Created by UpdraftPlus version ".$updraftplus->version." (https://updraftplus.com)\n");
3060 $this->stow("# WordPress Version: $wp_version, running on PHP ".phpversion()." (".$server_software."), MySQL $mysql_version\n");
3061 $this->stow("# ".sprintf('External database: (%s)', $this->dbinfo['user'].'@'.$this->dbinfo['host'].'/'.$this->dbinfo['name'])."\n");
3062 $this->stow("# Backup created by: ".untrailingslashit(site_url())."\n");
3063 $this->stow("# Table prefix: ".$this->table_prefix_raw."\n");
3064 $this->stow("# Filtered table prefix: ".$this->table_prefix."\n");
3065 }
3066
3067 $label = $updraftplus->jobdata_get('label');
3068 if (!empty($label)) $this->stow("# Label: $label\n");
3069
3070 $this->stow("\n# Generated: ".gmdate("l j. F Y H:i T")."\n");
3071 $this->stow("# Hostname: ".$this->dbinfo['host']."\n");
3072 $this->stow("# Database: ".UpdraftPlus_Manipulation_Functions::backquote($this->dbinfo['name'])."\n");
3073
3074 if (!empty($this->skipped_tables[$this->whichdb])) {
3075 if ('wp' == $this->whichdb) {
3076 $this->stow("# Skipped tables: " . implode(', ', $this->skipped_tables['wp'])."\n");
3077 } elseif (isset($this->skipped_tables[$this->dbinfo['name']])) {
3078 $this->stow("# Skipped tables: " . implode(', ', $this->skipped_tables[$this->dbinfo['name']])."\n");
3079 }
3080 }
3081
3082 $this->stow("# --------------------------------------------------------\n");
3083
3084 $this->stow("/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n");
3085 $this->stow("/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;\n");
3086 $this->stow("/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;\n");
3087 // N.B. Setting this as the mode matches the export behaviour of both mysqldump and phpMyAdmin
3088 $this->stow("/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n");
3089 $this->stow("/*!40101 SET NAMES ".$updraftplus->get_connection_charset($this->wpdb_obj)." */;\n");
3090 $this->stow("/*!40101 SET foreign_key_checks = 0 */;\n\n");
3091
3092 // This filter allows a user to add their own arbitrary content.
3093 if (false !== ($header_append = apply_filters('updraftplus_backup_db_header_append', false))) {
3094 $this->stow($header_append);
3095 }
3096
3097 }
3098
3099 /**
3100 * This function recursively packs the zip, dereferencing symlinks but packing into a single-parent tree for universal unpacking
3101 *
3102 * @param String $fullpath Full path
3103 * @param String $use_path_when_storing Controls the path to use when storing in the zip file
3104 * @param String $original_fullpath Original path
3105 * @param Integer $startlevels How deep within the directory structure the recursive operation has gone
3106 * @param Array $exclude passed by reference so that we can remove elements as they are matched - saves time checking against already-dealt-with objects]
3107 * @return Boolean
3108 */
3109 private function makezip_recursive_add($fullpath, $use_path_when_storing, $original_fullpath, $startlevels, &$exclude) {
3110
3111 global $updraftplus;
3112
3113 // Only BinZip supports symlinks. This means that as a consistent outcome, the only think that can be done with directory symlinks is either a) potentially duplicate the data or b) skip it. Whilst with internal WP entities (e.g. plugins) we definitely want the data, in the case of user-selected directories, we assume the user knew what they were doing when they chose the directory - i.e. we can skip symlink-accessed data that's outside.
3114 if (is_link($fullpath) && is_dir($fullpath) && 'more' == $this->whichone) {
3115 $updraftplus->log("Directory symlink encounted in more files backup: $use_path_when_storing -> ".readlink($fullpath).": skipping");
3116 return true;
3117 }
3118
3119 static $updraft_dir_realpath;
3120
3121 $updraft_dir_realpath = realpath($this->updraft_dir);
3122
3123 // De-reference. Important to do to both, because on Windows only doing it to one can make them non-equal, where they were previously equal - something which we later rely upon
3124 $fullpath = realpath($fullpath);
3125 $original_fullpath = realpath($original_fullpath);
3126
3127 // Is the place we've ended up above the original base? That leads to infinite recursion
3128 if (($fullpath !== $original_fullpath && strpos($original_fullpath, $fullpath) === 0) || ($original_fullpath == $fullpath && ((1== $startlevels && strpos($use_path_when_storing, '/') !== false) || (2 == $startlevels && substr_count($use_path_when_storing, '/') >1)))) {
3129 $updraftplus->log("Infinite recursion: symlink led us to $fullpath, which is within $original_fullpath");
3130 $updraftplus->log(__("Infinite recursion: consult your log for more information", 'updraftplus'), 'error');
3131 return false;
3132 }
3133
3134 // This is sufficient for the ones we have exclude options for - uploads, others, wpcore
3135 $stripped_storage_path = (1 == $startlevels) ? $use_path_when_storing : substr($use_path_when_storing, strpos($use_path_when_storing, '/') + 1);
3136 if (false !== ($fkey = array_search($stripped_storage_path, $exclude))) {
3137 $updraftplus->log("Entity excluded by configuration option: $stripped_storage_path");
3138 unset($exclude[$fkey]);
3139 return true;
3140 }
3141
3142 $if_altered_since = $this->makezip_if_altered_since;
3143
3144 if (is_file($fullpath)) {
3145 if (!empty($this->excluded_extensions) && $this->is_entity_excluded_by_extension($fullpath)) {
3146 $updraftplus->log("Entity excluded by configuration option (extension): ".basename($fullpath));
3147 } elseif (!empty($this->excluded_prefixes) && $this->is_entity_excluded_by_prefix($fullpath)) {
3148 $updraftplus->log("Entity excluded by configuration option (prefix): ".basename($fullpath));
3149 } elseif (!empty($this->excluded_wildcards) && $this->is_entity_excluded_by_wildcards(basename($fullpath))) {
3150 $updraftplus->log("Entity excluded by configuration option (wildcards): ".basename($fullpath));
3151 } elseif (apply_filters('updraftplus_exclude_file', false, $fullpath)) {
3152 $updraftplus->log("Entity excluded by filter: ".basename($fullpath));
3153 } elseif (is_readable($fullpath)) {
3154 $mtime = filemtime($fullpath);
3155 $key = ($fullpath == $original_fullpath) ? ((2 == $startlevels) ? $use_path_when_storing : $this->basename($fullpath)) : $use_path_when_storing.'/'.$this->basename($fullpath);
3156 if ($mtime > 0 && $mtime > $if_altered_since) {
3157 $this->zipfiles_batched[$fullpath] = $key;
3158 $this->makezip_recursive_batchedbytes += @filesize($fullpath);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the method.
3159 // @touch($zipfile);
3160 } else {
3161 $this->zipfiles_skipped_notaltered[$fullpath] = $key;
3162 }
3163 } else {
3164 $updraftplus->log("$fullpath: unreadable file");
3165 /* translators: %s: File path */
3166 $updraftplus->log(sprintf(__("%s: unreadable file - could not be backed up (check the file permissions and ownership)", 'updraftplus'), $fullpath), 'warning');
3167 }
3168 } elseif (is_dir($fullpath)) {
3169 if ($fullpath == $updraft_dir_realpath) {
3170 $updraftplus->log("Skip directory (UpdraftPlus backup directory): $use_path_when_storing");
3171 return true;
3172 }
3173
3174 if (apply_filters('updraftplus_exclude_directory', false, $fullpath, $use_path_when_storing)) {
3175 $updraftplus->log("Skip filtered directory: $use_path_when_storing");
3176 return true;
3177 }
3178
3179 if (file_exists($fullpath.'/.donotbackup')) {
3180 $updraftplus->log("Skip directory (.donotbackup file found): $use_path_when_storing");
3181 return true;
3182 }
3183
3184 if (!isset($this->existing_files[$use_path_when_storing])) $this->zipfiles_dirbatched[] = $use_path_when_storing;
3185
3186 if (!$dir_handle = @opendir($fullpath)) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3187 $updraftplus->log("Failed to open directory: $fullpath");
3188 /* translators: %s: Directory path */
3189 $updraftplus->log(sprintf(__("Failed to open directory (check the file permissions and ownership): %s", 'updraftplus'), $fullpath), 'error');
3190 return false;
3191 }
3192
3193 while (false !== ($e = readdir($dir_handle))) {
3194 if ('.' == $e || '..' == $e) continue;
3195
3196 if (is_link($fullpath.'/'.$e)) {
3197
3198 $deref = realpath($fullpath.'/'.$e);
3199
3200 if (false === $deref) {
3201 $updraftplus->log("$fullpath/$e: unfollowable link");
3202 $updraftplus->log(
3203 sprintf(
3204 /* translators: 1: File path, 2: Readlink output */
3205 __('%1$s: unfollowable link - could not be followed to back up (readlink=%2$s).', 'updraftplus'),
3206 $use_path_when_storing.'/'.$e,
3207 readlink($fullpath.'/'.$e)).' '.
3208 __("Possible causes include that the link points to an invalid or inaccessible location.", 'updraftplus'),
3209 'warning',
3210 "unrlink-$e");
3211 } elseif (is_file($deref)) {
3212 $use_stripped = $stripped_storage_path.'/'.$e;
3213 if (false !== ($fkey = array_search($use_stripped, $exclude))) {
3214 $updraftplus->log("Entity excluded by configuration option: $use_stripped");
3215 unset($exclude[$fkey]);
3216 } elseif (!empty($this->excluded_extensions) && $this->is_entity_excluded_by_extension($e)) {
3217 $updraftplus->log("Entity excluded by configuration option (extension): $use_stripped");
3218 } elseif (!empty($this->excluded_prefixes) && $this->is_entity_excluded_by_prefix($e)) {
3219 $updraftplus->log("Entity excluded by configuration option (prefix): $use_stripped");
3220 } elseif (!empty($this->excluded_wildcards) && $this->is_entity_excluded_by_wildcards($use_stripped)) {
3221 $updraftplus->log("Entity excluded by configuration option (wildcards): $use_stripped");
3222 } elseif (apply_filters('updraftplus_exclude_file', false, $deref, $use_stripped)) {
3223 $updraftplus->log("Entity excluded by filter: $use_stripped");
3224 } elseif (is_readable($deref)) {
3225 $mtime = filemtime($deref);
3226 if ($mtime > 0 && $mtime > $if_altered_since) {
3227 $this->zipfiles_batched[$deref] = $use_path_when_storing.'/'.$e;
3228 $this->makezip_recursive_batchedbytes += @filesize($deref);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3229 // @touch($zipfile);
3230 } else {
3231 $this->zipfiles_skipped_notaltered[$deref] = $use_path_when_storing.'/'.$e;
3232 }
3233 } else {
3234 $updraftplus->log("$deref: unreadable file (de-referenced from the link $e in $fullpath)");
3235 /* translators: %s: File path */
3236 $updraftplus->log(sprintf(__("%s: unreadable file - could not be backed up", 'updraftplus'), $deref), 'warning');
3237 }
3238 } elseif (is_dir($deref)) {
3239 $this->symlink_reversals[$deref] = $fullpath.'/'.$e;
3240 $this->makezip_recursive_add($deref, $use_path_when_storing.'/'.$e, $original_fullpath, $startlevels, $exclude);
3241 }
3242 } elseif (is_file($fullpath.'/'.$e)) {
3243 $use_stripped = $stripped_storage_path.'/'.$e;
3244 if (false !== ($fkey = array_search($use_stripped, $exclude))) {
3245 $updraftplus->log("Entity excluded by configuration option: $use_stripped");
3246 unset($exclude[$fkey]);
3247 } elseif (!empty($this->excluded_extensions) && $this->is_entity_excluded_by_extension($e)) {
3248 $updraftplus->log("Entity excluded by configuration option (extension): $use_stripped");
3249 } elseif (!empty($this->excluded_prefixes) && $this->is_entity_excluded_by_prefix($e)) {
3250 $updraftplus->log("Entity excluded by configuration option (prefix): $use_stripped");
3251 } elseif (!empty($this->excluded_wildcards) && $this->is_entity_excluded_by_wildcards($use_stripped)) {
3252 $updraftplus->log("Entity excluded by configuration option (wildcards): $use_stripped");
3253 } elseif (apply_filters('updraftplus_exclude_file', false, $fullpath.'/'.$e)) {
3254 $updraftplus->log("Entity excluded by filter: $use_stripped");
3255 } elseif (is_readable($fullpath.'/'.$e)) {
3256 $mtime = filemtime($fullpath.'/'.$e);
3257 if ($mtime > 0 && $mtime > $if_altered_since) {
3258 $this->zipfiles_batched[$fullpath.'/'.$e] = $use_path_when_storing.'/'.$e;
3259 $this->makezip_recursive_batchedbytes += @filesize($fullpath.'/'.$e);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3260 } else {
3261 $this->zipfiles_skipped_notaltered[$fullpath.'/'.$e] = $use_path_when_storing.'/'.$e;
3262 }
3263 } else {
3264 $updraftplus->log("$fullpath/$e: unreadable file");
3265 /* translators: %s: File path */
3266 $updraftplus->log(sprintf(__("%s: unreadable file - could not be backed up", 'updraftplus'), $use_path_when_storing.'/'.$e), 'warning', "unrfile-$e");
3267 }
3268 } elseif (is_dir($fullpath.'/'.$e)) {
3269 $use_stripped = $stripped_storage_path.'/'.$e;
3270 if ('wpcore' == $this->whichone && 'updraft' == $e && basename($use_path_when_storing) == 'wp-content' && (!defined('UPDRAFTPLUS_WPCORE_INCLUDE_UPDRAFT_DIRS') || !UPDRAFTPLUS_WPCORE_INCLUDE_UPDRAFT_DIRS)) {
3271 // This test, of course, won't catch everything - it just aims to make things better by default
3272 $updraftplus->log("Directory excluded for looking like a sub-site's internal UpdraftPlus directory (enable by defining UPDRAFTPLUS_WPCORE_INCLUDE_UPDRAFT_DIRS): ".$use_path_when_storing.'/'.$e);
3273 } elseif (!empty($this->excluded_wildcards) && $this->is_entity_excluded_by_wildcards($use_stripped)) {
3274 $updraftplus->log("Entity excluded by configuration option (wildcards): $use_stripped");
3275 } else {
3276 // no need to add_empty_dir here, as it gets done when we recurse
3277 $this->makezip_recursive_add($fullpath.'/'.$e, $use_path_when_storing.'/'.$e, $original_fullpath, $startlevels, $exclude);
3278 }
3279 }
3280 }
3281 closedir($dir_handle);
3282 } else {
3283 $updraftplus->log("Unexpected: path ($use_path_when_storing) fails both is_file() and is_dir()");
3284 }
3285
3286 return true;
3287
3288 }
3289
3290 /**
3291 * Get a list of excluded extensions
3292 *
3293 * @param Array $exclude - settings passed in
3294 *
3295 * @return Array
3296 */
3297 private function get_excluded_extensions($exclude) {
3298 if (!is_array($exclude)) $exclude = array();
3299 $exclude_extensions = array();
3300 foreach ($exclude as $ex) {
3301 if (preg_match('/^ext:(.+)$/i', $ex, $matches)) {
3302 $exclude_extensions[] = strtolower($matches[1]);
3303 }
3304 }
3305
3306 if (defined('UPDRAFTPLUS_EXCLUDE_EXTENSIONS')) {
3307 $exclude_from_define = explode(',', UPDRAFTPLUS_EXCLUDE_EXTENSIONS);
3308 foreach ($exclude_from_define as $ex) {
3309 $exclude_extensions[] = strtolower(trim($ex));
3310 }
3311 }
3312
3313 return $exclude_extensions;
3314 }
3315
3316 /**
3317 * Get a list of excluded prefixes
3318 *
3319 * @param Array $exclude - settings passed in
3320 *
3321 * @return Array - each is listed in lower case
3322 */
3323 private function get_excluded_prefixes($exclude) {
3324 if (!is_array($exclude)) $exclude = array();
3325 $exclude_prefixes = array();
3326 foreach ($exclude as $pref) {
3327 if (preg_match('/^prefix:(.+)$/i', $pref, $matches)) {
3328 $exclude_prefixes[] = strtolower($matches[1]);
3329 }
3330 }
3331 return $exclude_prefixes;
3332 }
3333
3334 /**
3335 * List all the wildcard patterns from the given excluded items
3336 *
3337 * @param Array $exclude the list of excluded items which may contain not just wildcard patterns but also specific file/directory names as well
3338 *
3339 * $exclude argument may contains data in an array format like below:
3340 * [
3341 * "snapshots" // definitely not a wildcard parttern, this could be directories/files named `snapshots` which are located in the root/parent directory
3342 * "2021/03/image.jpg", // not a wildcard parttern, this could be files/directories named `image.jpg` which are located in the 2021/03/ directory
3343 * "ext:zip", // not a wildcard pattern, this is to exclude all files that end with `zip` extension
3344 * "prefix:file-", // not a wildcard pattern, this is to exclude all files that begin with `file-` prefix
3345 * "2021/04", // not a wildcard pattern, this is to exclude all files/directories which are located in the 2021/04 directory
3346 * "backup*", // wildcard pattern that excludes all files/directories beginning with `backup` in the root/parent directory
3347 * "2021/*optimise*", // wildcard pattern that excludes all files/directories that have `optimise` anywhere in their names in the `2021` directory
3348 * "2021/04/*.tmp" // wildcard pattern that excludes all files/directories ending with `optimise` anywhere in their names in the `2021/04` directory
3349 * ]
3350 *
3351 * @return Array an array of wildcard patterns
3352 *
3353 * After the $exclude has gone through the regex parsing step, only excluded items containing valid wildcard patterns got captured and will return them in an array in a format like below:
3354 *
3355 * [
3356 * [
3357 * "directory_path" => "",
3358 * "pattern" => "backup*"
3359 * ],
3360 * [
3361 * "directory_path" => "2021\",
3362 * "pattern" => "*optimise*"
3363 * ],
3364 * [
3365 * "directory_path" => "2021\04\",
3366 * "pattern" => "*.tmp"
3367 * ]
3368 * ]
3369 */
3370 private function get_excluded_wildcards($exclude) {
3371 if (!is_array($exclude)) $exclude = array();
3372 $excluded_wildcards = array();
3373 foreach ($exclude as $wch) {
3374 // https://regex101.com/r/dMFI0P/1/
3375 if (preg_match('#(.*(?<!\\\)/)?(.*?(?<!\\\)\*.*)#i', $wch, $matches)) {
3376 // the regex will make sure only excluded items containing valid wildcard patterns get captured, it will lookup for asterisk char(s) at the very end of the string right after the last path separator (if any). e.g. foo/bar/b*a*z
3377 $excluded_wildcards[] = array(
3378 // in case the excluded item has doubled separators (e.g. dir1//dir2//file) or if the user added a directory separator at the beginning then trim and/or replace them
3379 'directory_path' => preg_replace(array('/^[\/\s]*/', '/\/\/*/', '/[\/\s]*$/'), array('', '/', ''), $matches[1]),
3380 'pattern' => $matches[2]
3381 );
3382 }
3383 }
3384 return $excluded_wildcards;
3385 }
3386
3387 /**
3388 * Check whether or not the given entity(file/directory) is excluded from the backup by matching it against a set of wildcard patterns
3389 *
3390 * @param String $entity the file/directory's stripped path
3391 * @return Boolean true if the entity is excluded, false otherwise
3392 */
3393 private function is_entity_excluded_by_wildcards($entity) {
3394 $entity_basename = untrailingslashit($entity);
3395 $entity_basename = substr_replace($entity_basename, '', 0, (false === strrpos($entity_basename, '/') ? 0 : strrpos($entity_basename, '/') + 1));
3396 foreach ($this->excluded_wildcards as $wch) {
3397 if (!is_array($wch) || empty($wch)) continue;
3398 if (substr_replace($entity, '', (int) strrpos($entity, '/'), strlen($entity) - (int) strrpos($entity, '/')) !== $wch['directory_path']) continue;
3399 if ('*' == substr($wch['pattern'], -1, 1) && '*' == substr($wch['pattern'], 0, 1) && strlen($wch['pattern']) > 2) {
3400 $wch['pattern'] = substr($wch['pattern'], 1, strlen($wch['pattern'])-2);
3401 $wch['pattern'] = str_replace('\*', '*', $wch['pattern']);
3402 if (strpos($entity_basename, $wch['pattern']) !== false) return true;
3403 } elseif ('*' == substr($wch['pattern'], -1, 1) && strlen($wch['pattern']) > 1) {
3404 $wch['pattern'] = substr($wch['pattern'], 0, strlen($wch['pattern'])-1);
3405 $wch['pattern'] = str_replace('\*', '*', $wch['pattern']);
3406 if (substr($entity_basename, 0, strlen($wch['pattern'])) == $wch['pattern']) return true;
3407 } elseif ('*' == substr($wch['pattern'], 0, 1) && strlen($wch['pattern']) > 1) {
3408 $wch['pattern'] = substr($wch['pattern'], 1);
3409 $wch['pattern'] = str_replace('\*', '*', $wch['pattern']);
3410 if (strlen($entity_basename) >= strlen($wch['pattern']) && substr($entity_basename, strlen($wch['pattern'])*-1) == $wch['pattern']) return true;
3411 }
3412 }
3413 return false;
3414 }
3415
3416 private function is_entity_excluded_by_extension($entity) {
3417 foreach ($this->excluded_extensions as $ext) {
3418 if (!$ext) continue;
3419 $eln = strlen($ext);
3420 if (strtolower(substr($entity, -$eln, $eln)) == $ext) return true;
3421 }
3422 return false;
3423 }
3424
3425 private function is_entity_excluded_by_prefix($entity) {
3426 $entity = basename($entity);
3427 foreach ($this->excluded_prefixes as $pref) {
3428 if (!$pref) continue;
3429 $eln = strlen($pref);
3430 if (strtolower(substr($entity, 0, $eln)) == $pref) return true;
3431 }
3432 return false;
3433 }
3434
3435 private function unserialize_gz_cache_file($file) {
3436 if (!$whandle = gzopen($file, 'r')) return false;
3437 global $updraftplus;
3438 $emptimes = 0;
3439 $var = '';
3440 while (!gzeof($whandle)) {
3441 $bytes = @gzread($whandle, 1048576);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3442 if (empty($bytes)) {
3443 $emptimes++;
3444 $updraftplus->log("Got empty gzread ($emptimes times)");
3445 if ($emptimes>2) return false;
3446 } else {
3447 $var .= $bytes;
3448 }
3449 }
3450 gzclose($whandle);
3451 return $updraftplus->unserialize($var);
3452 }
3453
3454 /**
3455 * Make Zip File.
3456 *
3457 * @param Array|String $source Caution: $source is allowed to be an array, not just a filename
3458 * @param String $backup_file_basename Name of backup file
3459 * @param String $whichone Backup entity type (e.g. 'plugins')
3460 * @param Boolean $retry_on_error Set to retry upon error
3461 * @return Boolean
3462 */
3463 private function make_zipfile($source, $backup_file_basename, $whichone, $retry_on_error = true) {
3464
3465 global $updraftplus;
3466
3467 $original_index = $this->index;
3468
3469 $itext = empty($this->index) ? '' : ($this->index+1);
3470 $destination_base = $backup_file_basename.'-'.$whichone.$itext.'.zip.tmp';
3471 // $destination is the temporary file (ending in .tmp)
3472 $destination = $this->updraft_dir.'/'.$destination_base;
3473
3474 // When to prefer PCL:
3475 // - We were asked to
3476 // - No zip extension present and no relevant method present
3477 // The zip extension check is not redundant, because method_exists segfaults some PHP installs, leading to support requests
3478
3479 // We need meta-info about $whichone
3480 $backupable_entities = $updraftplus->get_backupable_file_entities(true, false);
3481 // This is only used by one corner-case in BinZip
3482 // $this->make_zipfile_source = (isset($backupable_entities[$whichone])) ? $backupable_entities[$whichone] : $source;
3483 $this->make_zipfile_source = (is_array($source) && isset($backupable_entities[$whichone])) ? (('uploads' == $whichone) ? dirname($backupable_entities[$whichone]) : $backupable_entities[$whichone]) : dirname($source);
3484
3485 $this->existing_files = array();
3486 // Used for tracking compression ratios
3487 $this->existing_files_rawsize = 0;
3488 $this->existing_zipfiles_size = 0;
3489
3490 // Enumerate existing files
3491 // Usually first_linked_index is zero; the exception being with more files, where previous zips' contents are irrelevant
3492 for ($j = $this->first_linked_index; $j <= $this->index; $j++) {
3493 $jtext = (0 == $j) ? '' : $j+1;
3494 // This is, in a non-obvious way, compatible with filenames which indicate increments
3495 // $j does not need to start at zero; it should start at the index which the current entity split at. However, this is not directly known, and can only be deduced from examining the filenames. And, for other indexes from before the current increment, the searched-for filename won't exist (even if there is no cloud storage). So, this indirectly results in the desired outcome when we start from $j=0.
3496 $examine_zip = $this->updraft_dir.'/'.$backup_file_basename.'-'.$whichone.$jtext.'.zip'.(($j == $this->index) ? '.tmp' : '');
3497
3498 // This comes from https://wordpress.org/support/topic/updraftplus-not-moving-all-files-to-remote-server - where it appears that the jobdata's record of the split was done (i.e. database write), but the *earlier* rename of the .tmp file was not done (i.e. I/O lost). i.e. In theory, this should be impossible; but, the sychnronicity apparently cannot be fully relied upon in some setups. The check for the index being one behind is being conservative - there's no inherent reason why it couldn't be done for other indexes.
3499 // Note that in this 'impossible' case, no backup data was being lost - the design still ensures that the on-disk backup is fine. The problem was a gap in the sequence numbering of the zip files, leading to user confusion.
3500 // Other examples of this appear to be in HS#1001 and #1047
3501 if ($j != $this->index && !file_exists($examine_zip)) {
3502 $alt_examine_zip = $this->updraft_dir.'/'.$backup_file_basename.'-'.$whichone.$jtext.'.zip'.(($j == $this->index - 1) ? '.tmp' : '');
3503 if ($alt_examine_zip != $examine_zip && file_exists($alt_examine_zip) && is_readable($alt_examine_zip) && filesize($alt_examine_zip)>0) {
3504 $updraftplus->log("Looked-for zip file not found; but non-zero .tmp zip was, despite not being current index ($j != ".$this->index." - renaming zip (assume previous resumption's IO was lost before kill)");
3505 if (rename($alt_examine_zip, $examine_zip)) {
3506 clearstatcache();
3507 } else {
3508 $updraftplus->log("Rename failed - backup zips likely to not have sequential numbers (does not affect backup integrity, but can cause user confusion)");
3509 }
3510 }
3511 }
3512
3513 // If the file exists, then we should grab its index of files inside, and sizes
3514 // Then, when we come to write a file, we should check if it's already there, and only add if it is not
3515 if (file_exists($examine_zip) && is_readable($examine_zip) && filesize($examine_zip) > 0) {
3516
3517 // Do not use (which also means do not create) a manifest if the file is still a .tmp file, since this may not be complete. If we are in this place in the code from a resumption, creating a manifest here will mean the manifest becomes out-of-date if further files are added.
3518 $this->populate_existing_files_list_from_zip($examine_zip, substr($examine_zip, -4, 4) === '.zip');
3519
3520 // try_split is true if there have been no check-ins recently - or if it needs to be split anyway
3521 if ($j == $this->index) {
3522 if ($this->try_split) {
3523 if (filesize($examine_zip) > 50*1048576) {
3524 // We could, as a future enhancement, save this back to the job data, if we see a case that needs it
3525 $this->zip_split_every = max(
3526 (int) $this->zip_split_every/2,
3527 UPDRAFTPLUS_SPLIT_MIN*1048576,
3528 min(filesize($examine_zip)-1048576, $this->zip_split_every)
3529 );
3530 $updraftplus->jobdata_set('split_every', (int) ($this->zip_split_every/1048576));
3531 $updraftplus->log("No check-in on last two runs; bumping index and reducing zip split to: ".round($this->zip_split_every/1048576, 1)." MB");
3532 $do_bump_index = true;
3533 }
3534 unset($this->try_split);
3535 } elseif (filesize($examine_zip) > $this->zip_split_every) {
3536 $updraftplus->log(sprintf("Zip size is at/near split limit (%s MB / %s MB) - bumping index (from: %d)", filesize($examine_zip), round($this->zip_split_every/1048576, 1), $this->index));
3537 $do_bump_index = true;
3538 }
3539 }
3540
3541 } elseif (file_exists($examine_zip)) {
3542 $updraftplus->log("Zip file already exists, but is not readable or was zero-sized; will remove: ".basename($examine_zip));
3543 @unlink($examine_zip);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
3544 } elseif ($updraftplus->is_uploaded(basename($examine_zip))) {
3545 $this->populate_existing_files_list_from_zip($examine_zip, true);
3546 }
3547 }
3548
3549 $this->zip_last_ratio = ($this->existing_files_rawsize > 0) ? ($this->existing_zipfiles_size/$this->existing_files_rawsize) : 1;
3550
3551 $this->zipfiles_added = 0;
3552 $this->zipfiles_added_thisrun = 0;
3553 $this->zipfiles_dirbatched = array();
3554 $this->zipfiles_batched = array();
3555 $this->zipfiles_skipped_notaltered = array();
3556 $this->zipfiles_lastwritetime = time();
3557 $this->zip_basename = $this->updraft_dir.'/'.$backup_file_basename.'-'.$whichone;
3558
3559 if (!empty($do_bump_index)) $this->bump_index();
3560
3561 $error_occurred = false;
3562
3563 // Store this in its original form
3564 // $this->source = $source;
3565
3566 // Reset. This counter is used only with PcLZip, to decide if it's better to do it all-in-one
3567 $this->makezip_recursive_batchedbytes = 0;
3568 if (!is_array($source)) $source = array($source);
3569
3570 $exclude = $updraftplus->get_exclude($whichone);
3571
3572 $files_enumerated_at = $updraftplus->jobdata_get('files_enumerated_at');
3573 if (!is_array($files_enumerated_at)) $files_enumerated_at = array();
3574 $files_enumerated_at[$whichone] = time();
3575 $updraftplus->jobdata_set('files_enumerated_at', $files_enumerated_at);
3576
3577 $this->makezip_if_altered_since = is_array($this->altered_since) ? (isset($this->altered_since[$whichone]) ? $this->altered_since[$whichone] : -1) : -1;
3578
3579 // Reset
3580 $got_uploads_from_cache = false;
3581
3582 // Uploads: can/should we get it back from the cache?
3583 // || 'others' == $whichone
3584 if (('uploads' == $whichone || 'others' == $whichone) && function_exists('gzopen') && function_exists('gzread')) {
3585 $use_cache_files = false;
3586 $cache_file_base = $this->zip_basename.'-cachelist-'.$this->makezip_if_altered_since;
3587 // Cache file suffixes: -zfd.gz.tmp, -zfb.gz.tmp, -info.tmp, (possible)-zfs.gz.tmp
3588 if (file_exists($cache_file_base.'-zfd.gz.tmp') && file_exists($cache_file_base.'-zfb.gz.tmp') && file_exists($cache_file_base.'-info.tmp')) {
3589 // Cache files exist; shall we use them?
3590 $mtime = filemtime($cache_file_base.'-zfd.gz.tmp');
3591 // Require < 30 minutes old
3592 if (time() - $mtime < 1800) {
3593 $use_cache_files = true;
3594 }
3595 $any_failures = false;
3596 if ($use_cache_files) {
3597 $var = $this->unserialize_gz_cache_file($cache_file_base.'-zfd.gz.tmp');
3598 if (is_array($var)) {
3599 $this->zipfiles_dirbatched = $var;
3600 $var = $this->unserialize_gz_cache_file($cache_file_base.'-zfb.gz.tmp');
3601 if (is_array($var)) {
3602 $this->zipfiles_batched = $var;
3603 if (file_exists($cache_file_base.'-info.tmp')) {
3604 $var = $updraftplus->unserialize(file_get_contents($cache_file_base.'-info.tmp'));
3605 if (is_array($var) && isset($var['makezip_recursive_batchedbytes'])) {
3606 $this->makezip_recursive_batchedbytes = $var['makezip_recursive_batchedbytes'];
3607 if (file_exists($cache_file_base.'-zfs.gz.tmp')) {
3608 $var = $this->unserialize_gz_cache_file($cache_file_base.'-zfs.gz.tmp');
3609 if (is_array($var)) {
3610 $this->zipfiles_skipped_notaltered = $var;
3611 } else {
3612 $any_failures = true;
3613 }
3614 } else {
3615 $this->zipfiles_skipped_notaltered = array();
3616 }
3617 } else {
3618 $any_failures = true;
3619 }
3620 }
3621 } else {
3622 $any_failures = true;
3623 }
3624 } else {
3625 $any_failures = true;
3626 }
3627 if ($any_failures) {
3628 $updraftplus->log("Failed to recover file lists from existing cache files");
3629 // Reset it all
3630 $this->zipfiles_skipped_notaltered = array();
3631 $this->makezip_recursive_batchedbytes = 0;
3632 $this->zipfiles_batched = array();
3633 $this->zipfiles_dirbatched = array();
3634 } else {
3635 $updraftplus->log("File lists recovered from cache files; sizes: ".count($this->zipfiles_batched).", ".count($this->zipfiles_batched).", ".count($this->zipfiles_skipped_notaltered).")");
3636 $got_uploads_from_cache = true;
3637 }
3638 }
3639 }
3640 }
3641
3642 $time_counting_began = time();
3643
3644 $this->excluded_extensions = $this->get_excluded_extensions($exclude);
3645 $this->excluded_prefixes = $this->get_excluded_prefixes($exclude);
3646 $this->excluded_wildcards = $this->get_excluded_wildcards($exclude);
3647
3648 foreach ($source as $element) {
3649 // makezip_recursive_add($fullpath, $use_path_when_storing, $original_fullpath, $startlevels = 1, $exclude_array)
3650 if ('uploads' == $whichone) {
3651 if (empty($got_uploads_from_cache)) {
3652 $dirname = dirname($element);
3653 $basename = $this->basename($element);
3654 $add_them = $this->makezip_recursive_add($element, basename($dirname).'/'.$basename, $element, 2, $exclude);
3655 } else {
3656 $add_them = true;
3657 }
3658 } else {
3659 if (empty($got_uploads_from_cache)) {
3660 $add_them = $this->makezip_recursive_add($element, $this->basename($element), $element, 1, $exclude);
3661 } else {
3662 $add_them = true;
3663 }
3664 }
3665 if (is_wp_error($add_them) || false === $add_them) $error_occurred = true;
3666 }
3667
3668 $time_counting_ended = time();
3669
3670 // Cache the file scan, if it looks like it'll be useful
3671 // We use gzip to reduce the size as on hosts which limit disk I/O, the caching may make things worse
3672 // || 'others' == $whichone
3673 if (('uploads' == $whichone || 'others' == $whichone) && !$error_occurred && function_exists('gzopen') && function_exists('gzwrite')) {
3674 $cache_file_base = $this->zip_basename.'-cachelist-'.$this->makezip_if_altered_since;
3675
3676 // Just approximate - we're trying to avoid an otherwise-unpredictable PHP fatal error. Caching only happens if file enumeration took a long time - so presumably there are very many.
3677 $memory_needed_estimate = 0;
3678 foreach ($this->zipfiles_batched as $k => $v) {
3679 $memory_needed_estimate += strlen($k)+strlen($v)+12;
3680 }
3681
3682 // We haven't bothered to check if we just fetched the files from cache, as that shouldn't take a long time and so shouldn't trigger this
3683 // Let us suppose we need 15% overhead for gzipping
3684
3685 $memory_limit = ini_get('memory_limit');
3686 $memory_usage = round(@memory_get_usage(false)/1048576, 1);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3687 $memory_usage2 = round(@memory_get_usage(true)/1048576, 1);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3688
3689 if ($time_counting_ended-$time_counting_began > 20 && $updraftplus->verify_free_memory($memory_needed_estimate*0.15) && $whandle = gzopen($cache_file_base.'-zfb.gz.tmp', 'w')) {
3690 $updraftplus->log("File counting took a long time (".($time_counting_ended - $time_counting_began)."s); will attempt to cache results (memory_limit: $memory_limit (used: {$memory_usage}M | {$memory_usage2}M), estimated uncompressed bytes: ".round($memory_needed_estimate/1024, 1)." Kb)");
3691
3692 $buf = 'a:'.count($this->zipfiles_batched).':{';
3693 foreach ($this->zipfiles_batched as $file => $add_as) {
3694 $k = addslashes($file);
3695 $v = addslashes($add_as);
3696 $buf .= 's:'.strlen($k).':"'.$k.'";s:'.strlen($v).':"'.$v.'";';
3697 if (strlen($buf) > 1048576) {
3698 gzwrite($whandle, $buf, strlen($buf));
3699 $buf = '';
3700 }
3701 }
3702 $buf .= '}';
3703 $final = gzwrite($whandle, $buf);
3704 unset($buf);
3705
3706 if (!$final) {
3707 @unlink($cache_file_base.'-zfb.gz.tmp');// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
3708 @gzclose($whandle);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3709 } else {
3710 gzclose($whandle);
3711 if (!empty($this->zipfiles_skipped_notaltered)) {
3712 if ($shandle = gzopen($cache_file_base.'-zfs.gz.tmp', 'w')) {
3713 if (!gzwrite($shandle, serialize($this->zipfiles_skipped_notaltered))) {
3714 $aborted_on_skipped = true;
3715 }
3716 gzclose($shandle);
3717 } else {
3718 $aborted_on_skipped = true;
3719 }
3720 }
3721 if (!empty($aborted_on_skipped)) {
3722 @unlink($cache_file_base.'-zfs.gz.tmp');// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
3723 @unlink($cache_file_base.'-zfb.gz.tmp');// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
3724 } else {
3725 $info_array = array('makezip_recursive_batchedbytes' => $this->makezip_recursive_batchedbytes);
3726 if (!file_put_contents($cache_file_base.'-info.tmp', serialize($info_array))) {
3727 @unlink($cache_file_base.'-zfs.gz.tmp');// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
3728 @unlink($cache_file_base.'-zfb.gz.tmp');// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
3729 }
3730 if ($dhandle = gzopen($cache_file_base.'-zfd.gz.tmp', 'w')) {
3731 if (!gzwrite($dhandle, serialize($this->zipfiles_dirbatched))) {
3732 $aborted_on_dirbatched = true;
3733 }
3734 gzclose($dhandle);
3735 } else {
3736 $aborted_on_dirbatched = true;
3737 }
3738 if (!empty($aborted_on_dirbatched)) {
3739 // phpcs:disable Generic.PHP.NoSilencedErrors.Discouraged -- no error required if the file doesn't exist.
3740 @unlink($cache_file_base.'-zfs.gz.tmp');
3741 @unlink($cache_file_base.'-zfd.gz.tmp');
3742 @unlink($cache_file_base.'-zfb.gz.tmp');
3743 @unlink($cache_file_base.'-info.tmp');
3744 }
3745 }
3746 }
3747 }
3748
3749 /*
3750 Class variables that get altered:
3751 zipfiles_batched
3752 makezip_recursive_batchedbytes
3753 zipfiles_skipped_notaltered
3754 zipfiles_dirbatched
3755 Class variables that the result depends upon (other than the state of the filesystem):
3756 makezip_if_altered_since
3757 existing_files
3758 */
3759
3760 }
3761
3762 // Any not yet dispatched? Under our present scheme, at this point nothing has yet been despatched. And since the enumerating of all files can take a while, we can at this point do a further modification check to reduce the chance of overlaps.
3763 // This relies on us *not* touch()ing the zip file to indicate to any resumption 'behind us' that we're already here. Rather, we're relying on the combined facts that a) if it takes us a while to search the directory tree, then it should do for the one behind us too (though they'll have the benefit of cache, so could catch very fast) and b) we touch *immediately* after finishing the enumeration of the files to add.
3764 // $retry_on_error is here being used as a proxy for 'not the second time around, when there might be the remains of the file on the first time around'
3765 if ($retry_on_error) $updraftplus->check_recent_modification($destination);
3766 // Here we're relying on the fact that both PclZip and ZipArchive will happily operate on an empty file. Note that BinZip *won't* (for that, may need a new strategy - e.g. add the very first file on its own, in order to 'lay down a marker')
3767 if (empty($do_bump_index)) touch($destination);
3768
3769 if (count($this->zipfiles_dirbatched) > 0 || count($this->zipfiles_batched) > 0) {
3770
3771 $updraftplus->log(sprintf("Total entities for the zip file: %d directories, %d files (%d skipped as non-modified), %s MB", count($this->zipfiles_dirbatched), count($this->zipfiles_batched), count($this->zipfiles_skipped_notaltered), round($this->makezip_recursive_batchedbytes/1048576, 1)));
3772
3773 // No need to warn if we're going to retry anyway. (And if we get killed, the zip will be rescanned for its contents upon resumption).
3774 $warn_on_failures = ($retry_on_error) ? false : true;
3775 $add_them = $this->makezip_addfiles($warn_on_failures);
3776
3777 if (is_wp_error($add_them)) {
3778 foreach ($add_them->get_error_messages() as $msg) {
3779 $updraftplus->log("Error returned from makezip_addfiles: ".$msg);
3780 }
3781 $error_occurred = true;
3782 } elseif (false === $add_them) {
3783 $updraftplus->log("Error: makezip_addfiles returned false");
3784 $error_occurred = true;
3785 }
3786
3787 }
3788
3789 // Reset these variables because the index may have changed since we began
3790
3791 $itext = empty($this->index) ? '' : $this->index+1;
3792 $destination_base = $backup_file_basename.'-'.$whichone.$itext.'.zip.tmp';
3793 $destination = $this->updraft_dir.'/'.$destination_base;
3794
3795 // ZipArchive::addFile sometimes fails - there's nothing when we expected something.
3796 // Did not used to have || $error_occurred here. But it is better to retry, than to simply warn the user to check his logs.
3797 if (((file_exists($destination) || $this->index == $original_index) && @filesize($destination) < 90 && 'UpdraftPlus_ZipArchive' == $this->use_zip_object) || ($error_occurred && $retry_on_error)) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
3798 // This can be made more sophisticated if feedback justifies it. Currently we just switch to PclZip. But, it may have been a BinZip failure, so we could then try ZipArchive if that is available. If doing that, make sure that an infinite recursion isn't made possible.
3799 $updraftplus->log("makezip_addfiles(".$this->use_zip_object.") apparently failed (file=".basename($destination).", type=$whichone, size=".filesize($destination).") - retrying with PclZip");
3800 $saved_zip_object = $this->use_zip_object;
3801 $this->use_zip_object = 'UpdraftPlus_PclZip';
3802 $ret = $this->make_zipfile($source, $backup_file_basename, $whichone, false);
3803 $this->use_zip_object = $saved_zip_object;
3804 return $ret;
3805 }
3806
3807 // zipfiles_added > 0 means that $zip->close() has been called. i.e. An attempt was made to add something: something _should_ be there.
3808 // Why return true even if $error_occurred may be set? 1) Because in that case, a warning has already been logged. 2) Because returning false causes an error to be logged, which means it'll all be retried again. Also 3) this has been the pattern of the code for a long time, and the algorithm has been proven in the real-world: don't change what's not broken.
3809 // (file_exists($destination) || $this->index == $original_index) might be an alternative to $this->zipfiles_added > 0 - ? But, don't change what's not broken.
3810 if (false == $error_occurred || $this->zipfiles_added > 0) {
3811 return true;
3812 } else {
3813 $updraftplus->log("makezip failure: zipfiles_added=".$this->zipfiles_added.", error_occurred=".$error_occurred." (method=".$this->use_zip_object.")");
3814 return false;
3815 }
3816
3817 }
3818
3819 /**
3820 * This function is an ugly, conservative workaround for https://bugs.php.net/bug.php?id=62119. It does not aim to always work-around, but to ensure that nothing is made worse.
3821 *
3822 * @param String $element
3823 *
3824 * @return String
3825 */
3826 private function basename($element) {
3827 $dirname = dirname($element);
3828 $basename_manual = preg_replace('#^[\\/]+#', '', substr($element, strlen($dirname)));
3829 $basename = basename($element);
3830 if ($basename_manual != $basename) {
3831 $locale = setlocale(LC_CTYPE, "0");
3832 if ('C' == $locale) {
3833 setlocale(LC_CTYPE, 'en_US.UTF8');
3834 $basename_new = basename($element);
3835 if ($basename_new == $basename_manual) $basename = $basename_new;
3836 setlocale(LC_CTYPE, $locale);
3837 }
3838 }
3839 return $basename;
3840 }
3841
3842 /**
3843 * Determine if a file should be stored without compression
3844 *
3845 * @param String $file - the filename
3846 *
3847 * @return Boolean
3848 */
3849 private function file_should_be_stored_without_compression($file) {
3850 if (!is_array($this->extensions_to_not_compress)) return false;
3851 foreach ($this->extensions_to_not_compress as $ext) {
3852 $ext_len = strlen($ext);
3853 if (strtolower(substr($file, -$ext_len, $ext_len)) == $ext) return true;
3854 }
3855 return false;
3856 }
3857
3858 /**
3859 * This method will add a manifest file to the backup zip
3860 *
3861 * @param String $whichone - the type of backup (e.g. 'plugins', 'themes')
3862 *
3863 * @return Boolean - success/failure status
3864 */
3865 private function updraftplus_include_manifest($whichone) {
3866 global $updraftplus;
3867
3868 $manifest_name = "updraftplus-manifest.json";
3869 $manifest = trailingslashit($this->updraft_dir).$manifest_name;
3870
3871 $updraftplus->log(sprintf("Creating file manifest ($manifest_name) for incremental backup (included: %d, skipped: %d)", count($this->zipfiles_batched), count($this->zipfiles_skipped_notaltered)));
3872
3873 if (false === ($handle = fopen($manifest, 'w+'))) return $updraftplus->log("Failed to open manifest file ($manifest_name)");
3874
3875 $this->manifest_path = $manifest;
3876
3877 $version = 1;
3878
3879 $go_to_levels = array(
3880 'plugins' => 2,
3881 'themes' => 2,
3882 'uploads' => 3,
3883 'others' => 3
3884 );
3885
3886 $go_to_levels = apply_filters('updraftplus_manifest_go_to_level', $go_to_levels, $whichone);
3887
3888 $go_to_level = isset($go_to_levels[$whichone]) ? $go_to_levels[$whichone] : 'all';
3889
3890 $directory = '';
3891
3892 if ('more' == $whichone) {
3893 foreach ($this->zipfiles_batched as $index => $dir) {
3894 $directory = '"directory":"' . dirname($index) . '",';
3895 }
3896 }
3897
3898 if (false === fwrite($handle, '{"version":'.$version.',"type":"'.$whichone.'",'.$directory.'"listed_levels":"'.$go_to_level.'","contents":{"directories":[')) $updraftplus->log("First write to manifest file failed ($manifest_name)");
3899
3900 // First loop: find out which is the last entry, so that we don't write the comma after it
3901 $last_dir_index = false;
3902 foreach ($this->zipfiles_dirbatched as $index => $dir) {
3903 if ('all' !== $go_to_level && substr_count($dir, '/') > $go_to_level - 1) continue;
3904 $last_dir_index = $index;
3905 }
3906
3907 // Second loop: write out the entry
3908 foreach ($this->zipfiles_dirbatched as $index => $dir) {
3909 if ('all' !== $go_to_level && substr_count($dir, '/') > $go_to_level - 1) continue;
3910 fwrite($handle, json_encode($dir).(($index != $last_dir_index) ? ',' : ''));
3911 }
3912
3913 // Now do the same for files
3914 fwrite($handle, '],"files":[');
3915
3916 $last_file_index = false;
3917 foreach ($this->zipfiles_batched as $store_as) {
3918 if ('all' !== $go_to_level && substr_count($store_as, '/') > $go_to_level - 1) continue;
3919 $last_file_index = $store_as;
3920 }
3921 foreach ($this->zipfiles_skipped_notaltered as $store_as) {
3922 if ('all' !== $go_to_level && substr_count($store_as, '/') > $go_to_level - 1) continue;
3923 $last_file_index = $store_as;
3924 }
3925
3926 foreach ($this->zipfiles_batched as $store_as) {
3927 if ('all' !== $go_to_level && substr_count($store_as, '/') > $go_to_level - 1) continue;
3928 fwrite($handle, json_encode($store_as).(($store_as != $last_file_index) ? ',' : ''));
3929 }
3930
3931 foreach ($this->zipfiles_skipped_notaltered as $store_as) {
3932 if ('all' !== $go_to_level && substr_count($store_as, '/') > $go_to_level - 1) continue;
3933 fwrite($handle, json_encode($store_as).(($store_as != $last_file_index) ? ',' : ''));
3934 }
3935
3936 fwrite($handle, ']}}');
3937 fclose($handle);
3938
3939 $this->zipfiles_batched[$manifest] = $manifest_name;
3940
3941 $updraftplus->log("Successfully created file manifest (size: ".filesize($manifest).")");
3942
3943 return true;
3944 }
3945
3946 // Q. Why don't we only open and close the zip file just once?
3947 // A. Because apparently PHP doesn't write out until the final close, and it will return an error if anything file has vanished in the meantime. So going directory-by-directory reduces our chances of hitting an error if the filesystem is changing underneath us (which is very possible if dealing with e.g. 1GB of files)
3948
3949 /**
3950 * We batch up the files, rather than do them one at a time. So we are more efficient than open,one-write,close.
3951 * To call into here, the array $this->zipfiles_batched must be populated (keys=paths, values=add-to-zip-as values). It gets reset upon exit from here.
3952 *
3953 * @param Boolean $warn_on_failures - If set, then log at the 'warning' level
3954 *
3955 * @return Boolean|WP_Error
3956 */
3957 private function makezip_addfiles($warn_on_failures) {
3958
3959 global $updraftplus;
3960
3961 // Used to detect requests to bump the size
3962 $bump_index = false;
3963 $ret = true;
3964
3965 $zipfile = $this->zip_basename.((0 == $this->index) ? '' : ($this->index+1)).'.zip.tmp';
3966
3967 $maxzipbatch = $updraftplus->jobdata_get('maxzipbatch', 26214400);
3968 if ((int) $maxzipbatch < 1024) $maxzipbatch = 26214400;
3969
3970 // Short-circuit the null case, because we want to detect later if something useful happened
3971 if (0 == count($this->zipfiles_dirbatched) && 0 == count($this->zipfiles_batched)) return true;
3972
3973 // If on PclZip, then if possible short-circuit to a quicker method (makes a huge time difference - on a folder of 1500 small files, 2.6s instead of 76.6)
3974 // This assumes that makezip_addfiles() is only called once so that we know about all needed files (the new style)
3975 // This is rather conservative - because it assumes zero compression. But we can't know that in advance.
3976 $force_allinone = false;
3977 if (0 == $this->index && $this->makezip_recursive_batchedbytes < $this->zip_split_every) {
3978 // So far, we only have a processor for this for PclZip; but that check can be removed - need to address the below items
3979 // TODO: Is this really what we want? Always go all-in-one for < 500MB???? Should be more conservative? Or, is it always faster to go all-in-one? What about situations where we might want to auto-split because of slowness - check that that is still working.
3980 // TODO: Test this new method for PclZip - are we still getting the performance gains? Test for ZipArchive too.
3981 if ('UpdraftPlus_PclZip' == $this->use_zip_object && ($this->makezip_recursive_batchedbytes < 512*1048576 || (defined('UPDRAFTPLUS_PCLZIP_FORCEALLINONE') && UPDRAFTPLUS_PCLZIP_FORCEALLINONE == true && 'UpdraftPlus_PclZip' == $this->use_zip_object))) {
3982 $updraftplus->log("Only one archive required (".$this->use_zip_object.") - will attempt to do in single operation (data: ".round($this->makezip_recursive_batchedbytes/1024, 1)." KB, split: ".round($this->zip_split_every/1024, 1)." KB)");
3983 // $updraftplus->log("PclZip, and only one archive required - will attempt to do in single operation (data: ".round($this->makezip_recursive_batchedbytes/1024, 1)." KB, split: ".round($this->zip_split_every/1024, 1)." KB)");
3984 $force_allinone = true;
3985 // if(!class_exists('PclZip')) require_once(ABSPATH.'/wp-admin/includes/class-pclzip.php');
3986 // $zip = new PclZip($zipfile);
3987 // $remove_path = ($this->whichone == 'wpcore') ? untrailingslashit(ABSPATH) : WP_CONTENT_DIR;
3988 // $add_path = false;
3989 // Remove prefixes
3990 // $backupable_entities = $updraftplus->get_backupable_file_entities(true);
3991 // if (isset($backupable_entities[$this->whichone])) {
3992 // if ('plugins' == $this->whichone || 'themes' == $this->whichone || 'uploads' == $this->whichone) {
3993 // $remove_path = dirname($backupable_entities[$this->whichone]);
3994 // To normalise instead of removing (which binzip doesn't support, so we don't do it), you'd remove the dirname() in the above line, and uncomment the below one.
3995 // #$add_path = $this->whichone;
3996 // } else {
3997 // $remove_path = $backupable_entities[$this->whichone];
3998 // }
3999 // }
4000 // if ($add_path) {
4001 // $zipcode = $zip->create($this->source, PCLZIP_OPT_REMOVE_PATH, $remove_path, PCLZIP_OPT_ADD_PATH, $add_path);
4002 // } else {
4003 // $zipcode = $zip->create($this->source, PCLZIP_OPT_REMOVE_PATH, $remove_path);
4004 // }
4005 // if ($zipcode == 0) {
4006 // $updraftplus->log("PclZip Error: ".$zip->errorInfo(true), 'warning');
4007 // return $zip->errorCode();
4008 // } else {
4009 // UpdraftPlus_Job_Scheduler::something_useful_happened();
4010 // return true;
4011 // }
4012 }
4013 }
4014
4015 // 05-Mar-2013 - added a new check on the total data added; it appears that things fall over if too much data is contained in the cumulative total of files that were addFile'd without a close-open cycle; presumably data is being stored in memory. In the case in question, it was a batch of MP3 files of around 100MB each - 25 of those equals 2.5GB!
4016
4017 $data_added_since_reopen = 0;
4018 // static $data_added_this_resumption = 0;
4019 // $max_data_added_any_resumption = $updraftplus->jobdata_get('max_data_added_any_resumption', 0);
4020
4021 // The following array is used only for error reporting if ZipArchive::close fails (since that method itself reports no error messages - we have to track manually what we were attempting to add)
4022 $files_zipadded_since_open = array();
4023
4024 $zip = new $this->use_zip_object;
4025 if (file_exists($zipfile)) {
4026 $original_size = filesize($zipfile);
4027 // PHP 8.1 throws a deprecation notice if opening a zero-size file with ZipArchive, so in that situation, we remove and re-create it
4028 if ($original_size > 0) {
4029 $opencode = $zip->open($zipfile);
4030 clearstatcache();
4031 } elseif (0 === $original_size) {
4032 unlink($zipfile);
4033 } else {
4034 $opencode = false;
4035 }
4036 } else {
4037 $original_size = 0;
4038 }
4039
4040 if (0 === $original_size) {
4041 $create_code = (version_compare(PHP_VERSION, '5.2.12', '>') && defined('ZIPARCHIVE::CREATE')) ? ZIPARCHIVE::CREATE : 1;
4042 $opencode = $zip->open($zipfile, $create_code);
4043 }
4044
4045 /* translators: 1: Zip file path, 2: Error message */
4046 if (true !== $opencode) return new WP_Error('no_open', sprintf(__('Failed to open the zip file (%1$s) - %2$s', 'updraftplus'), $zipfile, $zip->last_error));
4047
4048 if (apply_filters('updraftplus_include_manifest', false, $this->whichone, $this)) {
4049 $this->updraftplus_include_manifest($this->whichone);
4050 }
4051
4052 // Give binzip the help it needs to deal with directory symlinks
4053 if (!empty($this->symlink_reversals) && is_callable(array($zip, 'ud_notify_symlink_reversals'))) {
4054 $zip->ud_notify_symlink_reversals($this->symlink_reversals);
4055 }
4056
4057 // Make sure all directories are created before we start creating files
4058 while ($dir = array_pop($this->zipfiles_dirbatched)) {
4059 $zip->addEmptyDir($dir);
4060 }
4061
4062 $zipfiles_added_thisbatch = 0;
4063
4064 // Go through all those batched files
4065 foreach ($this->zipfiles_batched as $file => $add_as) {
4066
4067 if (!file_exists($file)) {
4068 $updraftplus->log("File has vanished from underneath us; dropping: $add_as");
4069 continue;
4070 }
4071
4072 $fsize = filesize($file);
4073
4074 $large_file_warning_key = 'vlargefile_'.md5($this->whichone.'#'.$add_as);
4075
4076 if (defined('UPDRAFTPLUS_SKIP_FILE_OVER_SIZE') && UPDRAFTPLUS_SKIP_FILE_OVER_SIZE && $fsize > UPDRAFTPLUS_SKIP_FILE_OVER_SIZE) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the check.
4077 $updraftplus->log("File is larger than the user-configured (UPDRAFTPLUS_SKIP_FILE_OVER_SIZE) maximum (is: ".round($fsize/1024, 1)." KB); will skip: ".$add_as);
4078 continue;
4079 } elseif ($fsize > UPDRAFTPLUS_WARN_FILE_SIZE) {
4080
4081 /* translators: 1: File name, 2: File size in MB */
4082 $log_msg = __('A very large file was encountered: %1$s (size: %2$s Mb)', 'updraftplus');
4083
4084 // Was this warned about on a previous run?
4085 if ($updraftplus->warning_exists($large_file_warning_key)) {
4086 $updraftplus->log_remove_warning($large_file_warning_key);
4087 $large_file_warning_key .= '-2';
4088 $log_msg .= ' - '.__('a second attempt is being made (upon further failure it will be skipped)', 'updraftplus');
4089 } elseif ($updraftplus->warning_exists($large_file_warning_key.'-2') || $updraftplus->warning_exists($large_file_warning_key.'-final')) {
4090 $updraftplus->log_remove_warning($large_file_warning_key.'-2');
4091 $large_file_warning_key .= '-final';
4092 $log_msg .= ' - '.__('two unsuccessful attempts were made to include it, and it will now be omitted from the backup', 'updraftplus');
4093 }
4094
4095 $updraftplus->log(sprintf($log_msg, $add_as, round($fsize/1048576, 1)), 'warning', $large_file_warning_key);
4096
4097 if (preg_match('/-final$/', $large_file_warning_key)) continue;
4098 }
4099
4100 // Skips files that are already added
4101 if (!isset($this->existing_files[$add_as]) || $this->existing_files[$add_as] != $fsize) {
4102
4103 @touch($zipfile);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
4104
4105 $zip->addFile($file, $add_as);
4106 $zipfiles_added_thisbatch++;
4107
4108 if (method_exists($zip, 'setCompressionName') && $this->file_should_be_stored_without_compression($add_as) && false == $zip->setCompressionName($add_as, ZipArchive::CM_STORE)) {
4109 $updraftplus->log("Zip: setCompressionName failed on: $add_as");
4110 }
4111
4112 // N.B., Since makezip_addfiles() can get called more than once if there were errors detected, potentially $zipfiles_added_thisrun can exceed the total number of batched files (if they get processed twice).
4113 $this->zipfiles_added_thisrun++;
4114 $files_zipadded_since_open[] = array('file' => $file, 'addas' => $add_as);
4115
4116 $data_added_since_reopen += $fsize;
4117 // $data_added_this_resumption += $fsize;
4118 /* Conditions for forcing a write-out and re-open:
4119 - more than $maxzipbatch bytes have been batched
4120 - more than 2.0 seconds have passed since the last time we wrote
4121 - that adding this batch of data is likely already enough to take us over the split limit (and if that happens, then do actually split - to prevent a scenario of progressively tinier writes as we approach but don't actually reach the limit)
4122 - more than 500 files batched (should perhaps intelligently lower this as the zip file gets bigger - not yet needed)
4123 */
4124
4125 // Add 10% margin. It only really matters when the OS has a file size limit, exceeding which causes failure (e.g. 2GB on 32-bit)
4126 // Since we don't test before the file has been created (so that zip_last_ratio has meaningful data), we rely on max_zip_batch being less than zip_split_every - which should always be the case
4127 $reaching_split_limit = ($this->zip_last_ratio > 0 && $original_size>0 && ($original_size + 1.1*$data_added_since_reopen*$this->zip_last_ratio) > $this->zip_split_every) ? true : false;
4128
4129 if (!$force_allinone && ($zipfiles_added_thisbatch > UPDRAFTPLUS_MAXBATCHFILES || $reaching_split_limit || $data_added_since_reopen > $maxzipbatch || (time() - $this->zipfiles_lastwritetime) > 2)) {
4130
4131 // We are coming towards a limit and about to close the zip, check if this is a more file backup and the manifest file has made it into this zip if not add it
4132 if (apply_filters('updraftplus_include_manifest', false, $this->whichone, $this)) {
4133
4134 $manifest = false;
4135
4136 foreach ($files_zipadded_since_open as $info) {
4137 if ('updraftplus-manifest.json' == $info['file']) $manifest = true;
4138 }
4139
4140 if (!$manifest) {
4141 @touch($zipfile);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
4142 $path = array_search('updraftplus-manifest.json', $this->zipfiles_batched);
4143 $zip->addFile($path, 'updraftplus-manifest.json');
4144 $zipfiles_added_thisbatch++;
4145
4146 if (method_exists($zip, 'setCompressionName') && $this->file_should_be_stored_without_compression($this->zipfiles_batched[$path])) {
4147 if (false == $zip->setCompressionName($this->zipfiles_batched[$path], ZipArchive::CM_STORE)) {
4148 $updraftplus->log("Zip: setCompressionName failed on: $this->zipfiles_batched[$path]");
4149 }
4150 }
4151
4152 // N.B., Since makezip_addfiles() can get called more than once if there were errors detected, potentially $zipfiles_added_thisrun can exceed the total number of batched files (if they get processed twice).
4153 $this->zipfiles_added_thisrun++;
4154 $files_zipadded_since_open[] = array('file' => $path, 'addas' => 'updraftplus-manifest.json');
4155 $data_added_since_reopen += filesize($path);
4156 // $data_added_this_resumption += filesize($path);
4157 }
4158 }
4159
4160 if (function_exists('set_time_limit')) @set_time_limit(UPDRAFTPLUS_SET_TIME_LIMIT);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
4161 $something_useful_sizetest = false;
4162
4163 if ($data_added_since_reopen > $maxzipbatch) {
4164 $something_useful_sizetest = true;
4165 $updraftplus->log("Adding batch to zip file (".$this->use_zip_object."): over ".round($maxzipbatch/1048576, 1)." MB added on this batch (".round($data_added_since_reopen/1048576, 1)." MB, ".count($this->zipfiles_batched)." files batched, $zipfiles_added_thisbatch (".$this->zipfiles_added_thisrun.") added so far); re-opening (prior size: ".round($original_size/1024, 1).' KB)');
4166 } elseif ($zipfiles_added_thisbatch > UPDRAFTPLUS_MAXBATCHFILES) {
4167 $updraftplus->log("Adding batch to zip file (".$this->use_zip_object."): over ".UPDRAFTPLUS_MAXBATCHFILES." files added on this batch (".round($data_added_since_reopen/1048576, 1)." MB, ".count($this->zipfiles_batched)." files batched, $zipfiles_added_thisbatch (".$this->zipfiles_added_thisrun.") added so far); re-opening (prior size: ".round($original_size/1024, 1).' KB)');
4168 } elseif (!$reaching_split_limit) {
4169 $updraftplus->log("Adding batch to zip file (".$this->use_zip_object."): over 2.0 seconds have passed since the last write (".round($data_added_since_reopen/1048576, 1)." MB, $zipfiles_added_thisbatch (".$this->zipfiles_added_thisrun.") files added so far); re-opening (prior size: ".round($original_size/1024, 1).' KB)');
4170 } else {
4171 $updraftplus->log("Adding batch to zip file (".$this->use_zip_object."): possibly approaching split limit (".round($data_added_since_reopen/1048576, 1)." MB, $zipfiles_added_thisbatch (".$this->zipfiles_added_thisrun.") files added so far); last ratio: ".round($this->zip_last_ratio, 4)."; re-opening (prior size: ".round($original_size/1024, 1).' KB)');
4172 }
4173
4174 if (!$zip->close()) {
4175 // Though we will continue processing the files we've got, the final error code will be false, to allow a second attempt on the failed ones. This also keeps us consistent with a negative result for $zip->close() further down. We don't just retry here, because we have seen cases (with BinZip) where upon failure, the existing zip had actually been deleted. So, to be safe we need to re-scan the existing zips.
4176 $ret = false;
4177 $this->record_zip_error($files_zipadded_since_open, $zip->last_error, $warn_on_failures);
4178 }
4179
4180 $zipfiles_added_thisbatch = 0;
4181
4182 // This triggers a re-open, later
4183 unset($zip);
4184 $files_zipadded_since_open = array();
4185 // Call here, in case we've got so many big files that we don't complete the whole routine
4186 if (filesize($zipfile) > $original_size) {
4187
4188 // It is essential that this does not go above 1, even though in reality (and this can happen at the start, if just 1 file is added (e.g. due to >2.0s detection) the 'compressed' zip file may be *bigger* than the files stored in it. When that happens, if the ratio is big enough, it can then fire the "approaching split limit" detection (very) prematurely
4189 $this->zip_last_ratio = ($data_added_since_reopen > 0) ? min((filesize($zipfile) - $original_size)/$data_added_since_reopen, 1) : 1;
4190
4191 // We need a rolling update of this
4192 $original_size = filesize($zipfile);
4193
4194 // Move on to next zip?
4195 if ($reaching_split_limit || filesize($zipfile) > $this->zip_split_every) {
4196 $bump_index = true;
4197 // Take the filesize now because later we wanted to know we did clearstatcache()
4198 $bumped_at = round(filesize($zipfile)/1048576, 1);
4199 }
4200
4201 // Need to make sure that something_useful_happened() is always called
4202
4203 // How long since the current run began? If it's taken long (and we're in danger of not making it at all), or if that is foreseeable in future because of general slowness, then we should reduce the parameters.
4204 if (!$something_useful_sizetest) {
4205 UpdraftPlus_Job_Scheduler::something_useful_happened();
4206 } else {
4207
4208 // Do this as early as possible
4209 UpdraftPlus_Job_Scheduler::something_useful_happened();
4210
4211 $time_since_began = max(microtime(true)- $this->zipfiles_lastwritetime, 0.000001);
4212 $normalised_time_since_began = $time_since_began*($maxzipbatch/$data_added_since_reopen);
4213
4214 // Don't measure speed until after ZipArchive::close()
4215 $rate = round($data_added_since_reopen/$time_since_began, 1);
4216
4217 $updraftplus->log(sprintf("A useful amount of data was added after this amount of zip processing: %s s (normalised: %s s, rate: %s KB/s)", round($time_since_began, 1), round($normalised_time_since_began, 1), round($rate/1024, 1)));
4218
4219 // We want to detect not only that we need to reduce the size of batches, but also the capability to increase them. This is particularly important because of ZipArchive()'s (understandable, given the tendency of PHP processes being terminated without notice) practice of first creating a temporary zip file via copying before acting on that zip file (so the information is atomic). Unfortunately, once the size of the zip file gets over 100MB, the copy operation beguns to be significant. By the time you've hit 500MB on many web hosts the copy is the majority of the time taken. So we want to do more in between these copies if possible.
4220
4221 /* "Could have done more" - detect as:
4222 - A batch operation would still leave a "good chunk" of time in a run
4223 - "Good chunk" means that the time we took to add the batch is less than 50% of a run time
4224 - We can do that on any run after the first (when at least one ceiling on the maximum time is known)
4225 - But in the case where a max_execution_time is long (so that resumptions are never needed), and we're always on run 0, we will automatically increase chunk size if the batch took less than 6 seconds.
4226 */
4227
4228 // At one stage we had a strategy of not allowing check-ins to have more than 20s between them. However, once the zip file got to a certain size, PHP's habit of copying the entire zip file first meant that it *always* went over 18s, and thence a drop in the max size was inevitable - which was bad, because with the copy time being something that only grew, the outcome was less data being copied every time
4229
4230 // Gather the data. We try not to do this unless necessary (may be time-sensitive)
4231 if ($updraftplus->current_resumption >= 1) {
4232 $time_passed = $updraftplus->jobdata_get('run_times');
4233 if (!is_array($time_passed)) $time_passed = array();
4234 list($max_time, $timings_string, $run_times_known) = UpdraftPlus_Manipulation_Functions::max_time_passed($time_passed, $updraftplus->current_resumption-1, $this->first_run);// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Unused parameter is present because the method returns an array.
4235 } else {
4236 // $run_times_known = 0;
4237 // $max_time = -1;
4238 $run_times_known = 1;
4239 $max_time = microtime(true)-$updraftplus->opened_log_time;
4240 }
4241
4242 if ($normalised_time_since_began < 6 || ($updraftplus->current_resumption >= 1 && $run_times_known >= 1 && $time_since_began < 0.6*$max_time) || (0 == $updraftplus->current_resumption && $max_time > 240)) {
4243
4244 // How much can we increase it by?
4245 if ($normalised_time_since_began < 6 || 0 == $updraftplus->current_resumption) {
4246 if ($run_times_known > 0 && $max_time > 0) {
4247 $new_maxzipbatch = min(floor(max($maxzipbatch*6/$normalised_time_since_began, $maxzipbatch*((0.6*$max_time)/$normalised_time_since_began))), $this->zip_batch_ceiling);
4248 } else {
4249 // Maximum of 200MB in a batch
4250 $new_maxzipbatch = min(floor($maxzipbatch*6/$normalised_time_since_began), $this->zip_batch_ceiling);
4251 }
4252 } else {
4253 // Use up to 60% of available time
4254 $new_maxzipbatch = min(floor($maxzipbatch*((0.6*$max_time)/$normalised_time_since_began)), $this->zip_batch_ceiling);
4255 }
4256
4257 // Throttle increases - don't increase by more than 2x in one go - ???
4258 // $new_maxzipbatch = floor(min(2*$maxzipbatch, $new_maxzipbatch));
4259 // Also don't allow anything that is going to be more than 18 seconds - actually, that's harmful because of the basically fixed time taken to copy the file
4260 // $new_maxzipbatch = floor(min(18*$rate ,$new_maxzipbatch));
4261
4262 // Don't go above the split amount (though we expect that to be higher anyway, unless sending via email)
4263 $new_maxzipbatch = min($new_maxzipbatch, $this->zip_split_every);
4264
4265 // Don't raise it above a level that failed on a previous run
4266 $maxzipbatch_ceiling = $updraftplus->jobdata_get('maxzipbatch_ceiling');
4267 if (is_numeric($maxzipbatch_ceiling) && $maxzipbatch_ceiling > 20*1048576 && $new_maxzipbatch > $maxzipbatch_ceiling) {
4268 $updraftplus->log("Was going to raise maxzipbytes to $new_maxzipbatch, but this is too high: a previous failure led to the ceiling being set at $maxzipbatch_ceiling, which we will use instead");
4269 $new_maxzipbatch = $maxzipbatch_ceiling;
4270 }
4271
4272 // Final sanity check
4273 if ($new_maxzipbatch > 1048576) $updraftplus->jobdata_set('maxzipbatch', $new_maxzipbatch);
4274
4275 if ($new_maxzipbatch <= 1048576) {
4276 $updraftplus->log("Unexpected new_maxzipbatch value obtained (time=$time_since_began, normalised_time=$normalised_time_since_began, max_time=$max_time, data points known=$run_times_known, old_max_bytes=$maxzipbatch, new_max_bytes=$new_maxzipbatch)");
4277 } elseif ($new_maxzipbatch > $maxzipbatch) {
4278 $updraftplus->log("Performance is good - will increase the amount of data we attempt to batch (time=$time_since_began, normalised_time=$normalised_time_since_began, max_time=$max_time, data points known=$run_times_known, old_max_bytes=$maxzipbatch, new_max_bytes=$new_maxzipbatch)");
4279 } elseif ($new_maxzipbatch < $maxzipbatch) {
4280 // Ironically, we thought we were speedy...
4281 $updraftplus->log("Adjust: Reducing maximum amount of batched data (time=$time_since_began, normalised_time=$normalised_time_since_began, max_time=$max_time, data points known=$run_times_known, new_max_bytes=$new_maxzipbatch, old_max_bytes=$maxzipbatch)");
4282 } else {
4283 $updraftplus->log("Performance is good - but we will not increase the amount of data we batch, as we are already at the present limit (time=$time_since_began, normalised_time=$normalised_time_since_began, max_time=$max_time, data points known=$run_times_known, max_bytes=$maxzipbatch)");
4284 }
4285
4286 if ($new_maxzipbatch > 1048576) $maxzipbatch = $new_maxzipbatch;
4287 }
4288
4289 // Detect excessive slowness
4290 // Don't do this until we're on at least resumption 7, as we want to allow some time for things to settle down and the maximum time to be accurately known (since reducing the batch size unnecessarily can itself cause extra slowness, due to PHP's usage of temporary zip files)
4291
4292 // We use a percentage-based system as much as possible, to avoid the various criteria being in conflict with each other (i.e. a run being both 'slow' and 'fast' at the same time, which is increasingly likely as max_time gets smaller).
4293
4294 if (!$updraftplus->something_useful_happened && $updraftplus->current_resumption >= 7) {
4295
4296 UpdraftPlus_Job_Scheduler::something_useful_happened();
4297
4298 if ($run_times_known >= 5 && ($time_since_began > 0.8 * $max_time || $time_since_began + 7 > $max_time)) {
4299
4300 $new_maxzipbatch = max(floor($maxzipbatch*0.8), 20971520);
4301 if ($new_maxzipbatch < $maxzipbatch) {
4302 $maxzipbatch = $new_maxzipbatch;
4303 $updraftplus->jobdata_set("maxzipbatch", $new_maxzipbatch);
4304 $updraftplus->log("We are within a small amount of the expected maximum amount of time available; the zip-writing thresholds will be reduced (time_passed=$time_since_began, normalised_time_passed=$normalised_time_since_began, max_time=$max_time, data points known=$run_times_known, old_max_bytes=$maxzipbatch, new_max_bytes=$new_maxzipbatch)");
4305 } else {
4306 $updraftplus->log("We are within a small amount of the expected maximum amount of time available, but the zip-writing threshold is already at its lower limit (20MB), so will not be further reduced (max_time=$max_time, data points known=$run_times_known, max_bytes=$maxzipbatch)");
4307 }
4308 }
4309
4310 } else {
4311 UpdraftPlus_Job_Scheduler::something_useful_happened();
4312 }
4313 }
4314 $data_added_since_reopen = 0;
4315 } else {
4316 // ZipArchive::close() can take a very long time, which we want to know about
4317 UpdraftPlus_Job_Scheduler::record_still_alive();
4318 }
4319
4320 clearstatcache();
4321 $this->zipfiles_lastwritetime = time();
4322 }
4323 } elseif (0 == $this->zipfiles_added_thisrun) {
4324 // Update lastwritetime, because otherwise the 2.0-second-activity detection can fire prematurely (e.g. if it takes >2.0 seconds to process the previously-written files, then the detector fires after 1 file. This then can have the knock-on effect of having something_useful_happened() called, but then a subsequent attempt to write out a lot of meaningful data fails, and the maximum batch is not then reduced.
4325 // Testing shows that calling time() 1000 times takes negligible time
4326 $this->zipfiles_lastwritetime = time();
4327 }
4328
4329 $this->zipfiles_added++;
4330
4331 // Don't call something_useful_happened() here - nothing necessarily happens until close() is called
4332 if (0 == $this->zipfiles_added % 100) {
4333 $skip_dblog = ($this->zipfiles_added_thisrun > 0 || 0 == $this->zipfiles_added % 1000) ? false : true;
4334 $updraftplus->log("Zip: ".basename($zipfile).": ".$this->zipfiles_added." files added (on-disk size: ".round(@filesize($zipfile)/1024, 1)." KB)", 'notice', false, $skip_dblog);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
4335 }
4336
4337 if ($bump_index) {
4338 $updraftplus->log(sprintf("Zip size is at/near split limit (%s MB / %s MB) - bumping index (from: %d)", $bumped_at, round($this->zip_split_every/1048576, 1), $this->index));
4339 $bump_index = false;
4340 $this->bump_index();
4341 $zipfile = $this->zip_basename.($this->index+1).'.zip.tmp';
4342 }
4343
4344 if (empty($zip)) {
4345 $zip = new $this->use_zip_object;
4346
4347 // Give binzip the help it needs to deal with directory symlinks
4348 if (!empty($this->symlink_reversals) && is_callable(array($zip, 'ud_notify_symlink_reversals'))) {
4349 $zip->ud_notify_symlink_reversals($this->symlink_reversals);
4350 }
4351
4352 if (file_exists($zipfile)) {
4353 $original_size = filesize($zipfile);
4354 // PHP 8.1 throws a deprecation notice if opening a zero-size file with ZipArchive, so in that situation, we remove and re-create it
4355 if ($original_size > 0) {
4356 $opencode = $zip->open($zipfile);
4357 clearstatcache();
4358 } elseif (0 === $original_size) {
4359 unlink($zipfile);
4360 } else {
4361 $opencode = false;
4362 }
4363 } else {
4364 $original_size = 0;
4365 }
4366
4367 if (0 === $original_size) {
4368 $create_code = (version_compare(PHP_VERSION, '5.2.12', '>') && defined('ZIPARCHIVE::CREATE')) ? ZIPARCHIVE::CREATE : 1;
4369 $opencode = $zip->open($zipfile, $create_code);
4370 }
4371
4372 /* translators: 1: Zip file path, 2: Error message */
4373 if (true !== $opencode) return new WP_Error('no_open', sprintf(__('Failed to open the zip file (%1$s) - %2$s', 'updraftplus'), $zipfile, $zip->last_error));
4374 }
4375
4376 }
4377
4378 // Reset array
4379 $this->zipfiles_batched = array();
4380 $this->zipfiles_skipped_notaltered = array();
4381
4382 if (false == ($nret = $zip->close())) $this->record_zip_error($files_zipadded_since_open, $zip->last_error, $warn_on_failures);
4383
4384 if (apply_filters('updraftplus_include_manifest', false, $this->whichone, $this)) {
4385 if (!empty($this->manifest_path) && file_exists($this->manifest_path)) {
4386 $updraftplus->log('Removing manifest file: '.basename($this->manifest_path).': '.(@unlink($this->manifest_path) ? 'OK' : 'failed'));// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise if the file doesn't exist.
4387 }
4388 }
4389
4390 $this->zipfiles_lastwritetime = time();
4391 // May not exist if the last thing we did was bump
4392 if (file_exists($zipfile) && filesize($zipfile) > $original_size) UpdraftPlus_Job_Scheduler::something_useful_happened();
4393
4394 // Move on to next archive?
4395 if (file_exists($zipfile) && filesize($zipfile) > $this->zip_split_every) {
4396 $updraftplus->log(sprintf("Zip size has gone over split limit (%s, %s) - bumping index (%d)", round(filesize($zipfile)/1048576, 1), round($this->zip_split_every/1048576, 1), $this->index));
4397 $this->bump_index();
4398 }
4399
4400 $manifest = preg_replace('/\.tmp$/', '.list.tmp', $zipfile);
4401 if (!file_exists($manifest)) $this->write_zip_manifest_from_zip($zipfile);
4402
4403 clearstatcache();
4404
4405 return (false == $ret) ? false : $nret;
4406 }
4407
4408 private function record_zip_error($files_zipadded_since_open, $msg, $warn = true) {
4409 global $updraftplus;
4410
4411 if (!empty($updraftplus->cpanel_quota_readable)) {
4412 $hosting_bytes_free = $updraftplus->get_hosting_disk_quota_free();
4413 if (is_array($hosting_bytes_free)) {
4414 $perc = round(100*$hosting_bytes_free[1]/(max($hosting_bytes_free[2], 1)), 1);
4415 $quota_free_msg = sprintf('Free disk space in account: %s (%s used)', round($hosting_bytes_free[3]/1048576, 1)." MB", "$perc %");
4416 $updraftplus->log($quota_free_msg);
4417 if ($hosting_bytes_free[3] < 1048576*50) {
4418 $quota_low = true;
4419 $quota_free_mb = round($hosting_bytes_free[3]/1048576, 1);
4420 /* translators: %s: Remaining free space in MB */
4421 $updraftplus->log(sprintf(__('Your free space in your hosting account is very low - only %s Mb remain', 'updraftplus'), $quota_free_mb), 'warning', 'lowaccountspace'.$quota_free_mb);
4422 }
4423 }
4424 }
4425
4426 // Always warn of this
4427 if (is_string($msg) && strpos($msg, 'File Size Limit Exceeded') !== false && 'UpdraftPlus_BinZip' == $this->use_zip_object) {
4428 $updraftplus->log(
4429 sprintf(
4430 /* translators: %s: Error message */
4431 __('The zip engine returned the message: %s.', 'updraftplus'),
4432 'File Size Limit Exceeded').
4433 __('Go here for more information.', 'updraftplus').
4434 ' https://teamupdraft.com/documentation/updraftplus/topics/backing-up/troubleshooting/what-should-i-do-if-i-see-the-message-file-size-limit-exceeded/?utm_source=udp-plugin&utm_medium=referral&utm_campaign=paac&utm_content=unknown&utm_creative_format=unknown',
4435 'warning',
4436 'zipcloseerror-filesizelimit');
4437 } elseif ($warn) {
4438 $warn_msg = __('A zip error occurred', 'updraftplus').' - ';
4439 if (!empty($quota_low)) {
4440 /* translators: %s: Help URL */
4441 $warn_msg = sprintf(__('your web hosting account is full; please see: %s', 'updraftplus'), 'https://updraftplus.com/faqs/how-much-free-disk-space-do-i-need-to-create-a-backup/');
4442 } else {
4443 $warn_msg .= __('check your log for more details.', 'updraftplus');
4444 }
4445 $updraftplus->log($warn_msg, 'warning', 'zipcloseerror-'.$this->whichone);
4446 }
4447
4448 $updraftplus->log("The attempt to close the zip file returned an error ($msg). List of files we were trying to add follows (check their permissions).");
4449
4450 foreach ($files_zipadded_since_open as $ffile) {
4451 $updraftplus->log("File: ".$ffile['addas']." (exists: ".(int) @file_exists($ffile['file']).", is_readable: ".(int) @is_readable($ffile['file'])." size: ".@filesize($ffile['file']).')', 'notice', false, true);// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the function.
4452 }
4453 }
4454
4455 /**
4456 * Bump the zip index number. No parameters or returned value, since it is dealing with class variables.
4457 */
4458 private function bump_index() {
4459 global $updraftplus;
4460 $youwhat = $this->whichone;
4461
4462 $timetaken = max(microtime(true)-$this->zip_microtime_start, 0.000001);
4463
4464 $itext = (0 == $this->index) ? '' : ($this->index+1);
4465 $full_path = $this->zip_basename.$itext.'.zip';
4466
4467 $checksums = $updraftplus->which_checksums();
4468
4469 $checksum_description = '';
4470
4471 foreach ($checksums as $checksum) {
4472
4473 $cksum = hash_file($checksum, $full_path.'.tmp');
4474 $updraftplus->jobdata_set($checksum.'-'.$youwhat.$this->index, $cksum);
4475 if ($checksum_description) $checksum_description .= ', ';
4476 $checksum_description .= "$checksum: $cksum";
4477
4478 }
4479
4480 $next_full_path = $this->zip_basename.($this->index+2).'.zip';
4481 // We touch the next zip before renaming the temporary file; this indicates that the backup for the entity is not *necessarily* finished
4482 touch($next_full_path.'.tmp');
4483
4484 if (file_exists($full_path.'.tmp') && filesize($full_path.'.tmp') > 0) {
4485 if (!rename($full_path.'.tmp', $full_path)) {
4486 $updraftplus->log("Rename failed for $full_path.tmp");
4487 } else {
4488 $manifest = $full_path.'.list.tmp';
4489 if (!file_exists($manifest)) $this->write_zip_manifest_from_zip($full_path);
4490 UpdraftPlus_Job_Scheduler::something_useful_happened();
4491 }
4492 }
4493
4494 $kbsize = filesize($full_path)/1024;
4495 $rate = round($kbsize/$timetaken, 1);
4496 $updraftplus->log("Created ".$this->whichone." zip (".$this->index.") - ".round($kbsize, 1)." KB in ".round($timetaken, 1)." s ($rate KB/s) (checksums: $checksum_description)");
4497 $this->zip_microtime_start = microtime(true);
4498
4499 // No need to add $itext here - we can just delete any temporary files for this zip
4500 UpdraftPlus_Filesystem_Functions::clean_temporary_files('_'.$updraftplus->file_nonce."-".$youwhat, 600);
4501
4502 $prior_index = $this->index;
4503 $this->index++;
4504 $this->job_file_entities[$youwhat]['index'] = $this->index;
4505 $updraftplus->jobdata_set('job_file_entities', $this->job_file_entities);
4506 $this->maybe_cloud_backup(basename($full_path), $this->whichone, $prior_index);
4507 }
4508
4509 /**
4510 * This function will populate class variables (given below) with a list of files found inside the passed in zip
4511 *
4512 * @param string $zip_path - the zip file name we want to list files for; must end in .tmp
4513 * @param boolean $read_from_manifest - a boolean to indicate if we should try to read from the manifest or not
4514 *
4515 * @uses self::$existing_files
4516 * @uses self::$existing_files_rawsize
4517 * @uses self::$existing_zipfiles_size
4518 *
4519 * @return void
4520 */
4521 private function populate_existing_files_list_from_zip($zip_path, $read_from_manifest) {
4522 global $updraftplus;
4523
4524 // Get the name of the final manifest file
4525 if (preg_match('/\.tmp$/', $zip_path)) {
4526 $manifest = preg_replace('/\.tmp$/', '.list.tmp', $zip_path);
4527 } else {
4528 $manifest = $zip_path.'.list.tmp';
4529 }
4530
4531 if ($read_from_manifest && file_exists($manifest)) {
4532 $manifest_contents = json_decode(file_get_contents($manifest), true);
4533
4534 if (empty($manifest_contents)) {
4535 $updraftplus->log("Zip manifest file found, but reading failed: ".basename($manifest));
4536 } elseif (!empty($manifest_contents['files'])) {
4537 $this->existing_files = array_merge($this->existing_files, $manifest_contents['files'][0]);
4538 $updraftplus->log('Successfully read zip manifest file contents ('.basename($manifest).')');
4539 return;
4540 } else {
4541 $updraftplus->log("Zip manifest file found, but no files contents were found: ".basename($manifest));
4542 }
4543 } elseif ($read_from_manifest) {
4544 $updraftplus->log("No zip manifest file found; will create one");
4545 }
4546
4547 $zip = new $this->use_zip_object;
4548 if (true !== $zip->open($zip_path)) {
4549 $updraftplus->log("Could not open zip file to examine (".$zip->last_error."); will remove: ".basename($zip_path));
4550 unlink($zip_path);
4551 } else {
4552
4553 $this->existing_zipfiles_size += filesize($zip_path);
4554
4555 // Don't put this in the for loop, or the magic __get() method which accessing the property invokes gets repeatedly called every time the loop goes round
4556 $numfiles = $zip->numFiles;
4557
4558 if (false === $numfiles) {
4559 $updraftplus->log("Could not read any files from the zip (".$zip->last_error."): ".basename($zip_path));
4560 $zip->close();
4561 return;
4562 }
4563
4564 for ($i=0; $i < $numfiles; $i++) {
4565 $si = $zip->statIndex($i);
4566 $name = $si['name'];
4567 // Exclude folders
4568 if ('/' == substr($name, -1)) continue;
4569 if (!isset($this->existing_files[$name])) {
4570 $this->existing_files[$name] = $si['size'];
4571 $this->existing_files_rawsize += $si['size'];
4572 }
4573 }
4574
4575 @$zip->close();// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the method.
4576
4577 $updraftplus->log(basename($zip_path).": Zip file already exists, with ".count($this->existing_files)." files");
4578
4579 // If this is a .tmp file (partial zip) then return we do not want to create an incomplete manifest file
4580 if (preg_match('/\.tmp$/', $zip_path)) return;
4581
4582 $manifest = $zip_path.'.list-temp.tmp';
4583
4584 $this->write_zip_manifest_from_list($manifest, $this->existing_files);
4585 }
4586 }
4587
4588 /**
4589 * This function will get a list of files found inside the passed in zip and call the function to create the zip manifest, returns true on success and false on failure
4590 *
4591 * @uses self::write_zip_manifest_from_list()
4592 * @param string $zip_path - the zip file path; must end in .tmp
4593 *
4594 * @return boolean - returns true on success and false on failure
4595 */
4596 private function write_zip_manifest_from_zip($zip_path) {
4597 global $updraftplus;
4598
4599 $zip_files = array();
4600
4601 $zip = new $this->use_zip_object;
4602 if (true !== $zip->open($zip_path)) {
4603 $updraftplus->log("Could not open zip file to examine (".$zip->last_error."); file: ".basename($zip_path));
4604 return false;
4605 } else {
4606 // Don't put this in the for loop, or the magic __get() method gets repeatedly called every time the loop goes round
4607 $numfiles = $zip->numFiles;
4608
4609 if (false === $numfiles) $updraftplus->log("write_zip_manifest_from_zip(): Could not read any files from the zip: (".basename($zip_path).") Zip error: (".$zip->last_error.")");
4610
4611 for ($i=0; $i < $numfiles; $i++) {
4612 $si = $zip->statIndex($i);
4613 $name = $si['name'];
4614 // Exclude folders
4615 if ('/' == substr($name, -1)) continue;
4616 $zip_files[$name] = $si['size'];
4617 }
4618
4619 @$zip->close();// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- Silenced to suppress errors that may arise because of the method.
4620 }
4621
4622 if (empty($zip_files)) {
4623 $updraftplus->log("Did not find any files in the zip: ".basename($zip_path));
4624 return false;
4625 }
4626
4627 if (preg_match('/\.tmp$/', $zip_path)) {
4628 $manifest = preg_replace('/\.tmp$/', '.list-temp.tmp', $zip_path);
4629 } else {
4630 $manifest = $zip_path.'.list-temp.tmp';
4631 }
4632
4633 $this->write_zip_manifest_from_list($manifest, $zip_files);
4634
4635 return true;
4636 }
4637
4638 /**
4639 * This function will create and write the contents of the zip manifest
4640 *
4641 * @param string $manifest - path of the manifest file
4642 * @param array $zip_files - an array of files and their sizes
4643 *
4644 * @return boolean - returns false on failure to write
4645 */
4646 private function write_zip_manifest_from_list($manifest, $zip_files) {
4647 global $updraftplus;
4648
4649 $updraftplus->log('Creating zip file manifest ('.basename($manifest).')');
4650
4651 if (false === ($handle = fopen($manifest, 'w+'))) {
4652 $updraftplus->log('Failed to open zip manifest file ('.basename($manifest).')');
4653 return false;
4654 }
4655
4656 $version = 1;
4657
4658 if (false === fwrite($handle, '{"version":'.$version.', "files":[{')) {
4659 $updraftplus->log('First write to manifest file failed ('.basename($manifest).')');
4660 return false;
4661 }
4662
4663 $last_dir_index = key(array_slice($zip_files, -1, 1, true));
4664
4665 foreach ($zip_files as $name => $size) {
4666 fwrite($handle, json_encode($name).' : '.$size.(($name != $last_dir_index) ? ',' : ''));
4667 }
4668
4669 fwrite($handle, '}]}');
4670 fclose($handle);
4671
4672 $updraftplus->log("Successfully created zip file manifest (size: ".filesize($manifest).")");
4673
4674 $final_manifest = preg_replace('/\.list-temp.tmp$/', '.list.tmp', $manifest);
4675 rename($manifest, $final_manifest);
4676 }
4677
4678 /**
4679 * Returns the member of the array with key (int)0, as a new array. This function is used as a callback for array_map().
4680 *
4681 * @param Array $a - the array
4682 *
4683 * @return Array - with keys 'name' and 'type'
4684 */
4685 private function cb_get_name_base_type($a) {
4686 return array('name' => $a[0], 'type' => 'BASE TABLE');
4687 }
4688
4689 /**
4690 * Returns the members of the array with keys (int)0 and (int)1, as part of a new array.
4691 *
4692 * @param Array $a - the array
4693 *
4694 * @return Array - keys are 'name' and 'type'
4695 */
4696 private function cb_get_name_type($a) {
4697 return array('name' => $a[0], 'type' => $a[1]);
4698 }
4699
4700 /**
4701 * Returns the member of the array with key (string)'name'. This function is used as a callback for array_map().
4702 *
4703 * @param Array $a - the array
4704 *
4705 * @return Mixed - the value with key (string)'name'
4706 */
4707 private function cb_get_name($a) {
4708 return $a['name'];
4709 }
4710
4711 /**
4712 * Exclude files from backup
4713 *
4714 * @param Boolean $filter initial boolean value of whether the given file is excluded or not
4715 * @param String $file the full path of the filename to be checked
4716 * @return Boolean true if the specified file will be excluded, false otherwise
4717 */
4718 public function backup_exclude_file($filter, $file) {
4719 foreach ($this->backup_excluded_patterns as $pattern) {
4720 if (0 === stripos($file, $pattern['directory']) && preg_match($pattern['regex'], $file)) return true;
4721 }
4722 return $filter;
4723 }
4724
4725 /**
4726 * Adds WooCommerce-related information to the site info header for backup purposes.
4727 *
4728 * This method checks if WooCommerce is active and appends relevant information about
4729 * WooCommerce's state to the provided site info array. Specifically, it includes:
4730 * - Whether WooCommerce is active.
4731 * - Whether the High-Performance Order Storage (HPOS) feature is enabled.
4732 *
4733 * @param array $site_info The existing array of site information to which WooCommerce details will be appended.
4734 * @return array The updated site info array with WooCommerce-related details included.
4735 */
4736 public function backup_db_header_site_info_woocommerce($site_info) {
4737 if (class_exists('WooCommerce')) {
4738 $wc_version = defined('WC_VERSION') ? WC_VERSION : get_option('woocommerce_version');
4739 $info = 'WooCommerce='.$wc_version;
4740
4741 if (class_exists('Automattic\WooCommerce\Utilities\OrderUtil') && is_callable(array('Automattic\WooCommerce\Utilities\OrderUtil', 'custom_orders_table_usage_is_enabled')) && call_user_func(array('Automattic\WooCommerce\Utilities\OrderUtil', 'custom_orders_table_usage_is_enabled'))) {
4742 $info .= ',HPOS=enabled';
4743 }
4744
4745 $site_info[] = $info;
4746 }
4747 return $site_info;
4748 }
4749 }
4750
4751 class UpdraftPlus_WPDB_OtherDB extends wpdb {
4752 /**
4753 * This adjusted bail() does two things: 1) Never dies and 2) logs in the UD log
4754 *
4755 * @param String $message Error text
4756 * @param String $error_code Error code
4757 *
4758 * @return Boolean
4759 */
4760 public function bail($message, $error_code = '500') {
4761 global $updraftplus;
4762 if ('db_connect_fail' == $error_code) $message = 'Connection failed: check your access details, that the database server is up, and that the network connection is not firewalled.';
4763 $updraftplus->log("WPDB_OtherDB error: $message ($error_code)");
4764 // Now do the things that would have been done anyway
4765 if (class_exists('WP_Error')) {
4766 $this->error = new WP_Error($error_code, $message);
4767 } else {
4768 $this->error = $message;
4769 }
4770 return false;
4771 }
4772 }
4773