PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.2
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.2
4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Backend / Modules / Jobs / Delete.php
wp-staging / Backend / Modules / Jobs Last commit date
Cleaners 3 months ago Exceptions 5 years ago Cancel.php 7 months ago CancelUpdate.php 7 months ago Cloning.php 2 weeks ago CloningProcess.php 1 year ago Data.php 7 months ago Database.php 3 months ago Delete.php 4 months ago Directories.php 4 months ago Files.php 1 week ago Finish.php 2 months ago Job.php 2 weeks ago JobExecutable.php 7 months ago Logs.php 3 years ago PreserveDataFirstStep.php 1 month ago PreserveDataSecondStep.php 1 month ago ProcessLock.php 1 year ago Scan.php 4 months ago SearchReplace.php 5 months ago TotalStepsAreNumberOfTables.php 5 years ago Updating.php 2 weeks ago
Delete.php
599 lines
1 <?php
2
3 namespace WPStaging\Backend\Modules\Jobs;
4
5 use Exception;
6 use FilesystemIterator;
7 use mysqli;
8 use stdClass;
9 use wpdb;
10 use WPStaging\Backend\Modules\Jobs\Exceptions\CloneNotFoundException;
11 use WPStaging\Core\Utils\Logger;
12 use WPStaging\Core\WPStaging;
13 use WPStaging\Framework\Filesystem\Filesystem;
14 use WPStaging\Framework\Filesystem\FilesystemExceptions;
15 use WPStaging\Staging\Sites;
16 use WPStaging\Framework\Utils\Sanitize;
17 use WPStaging\Framework\Utils\Strings;
18
19 /**
20 * Class Delete
21 * @todo Remove when proper clone cancel job is added!
22 * @package WPStaging\Backend\Modules\Jobs
23 */
24 class Delete extends Job
25 {
26 /**
27 * @var string
28 */
29 const DELETE_STATUS_FINISHED = 'finished';
30
31 /**
32 * @var string
33 */
34 const DELETE_STATUS_UNFINISHED = 'unfinished';
35
36 /**
37 * @var stdClass|false
38 */
39 private $clone = false;
40
41 /**
42 * The path to delete
43 * @var string
44 */
45 private $deleteDir;
46
47 /**
48 * @var null|object|array
49 */
50 private $tables = null;
51
52 /**
53 * @var object|null
54 */
55 private $job = null;
56
57 /**
58 * @var wpdb
59 */
60 public $wpdb;
61
62 /**
63 * @var bool|null
64 */
65 private $isExternalDb;
66
67 /** @var Strings */
68 private $strings;
69
70 /** @var Sanitize */
71 private $sanitize;
72
73 public function __construct()
74 {
75 parent::__construct();
76
77 /** @var Sanitize */
78 $this->sanitize = WPStaging::make(Sanitize::class);
79 $this->deleteDir = !empty($_POST['deleteDir']) ? $this->sanitize->sanitizePath($_POST['deleteDir']) : '';
80 $this->strings = new Strings();
81 }
82
83 /**
84 * @param bool $isExternal
85 * @return void
86 */
87 public function setIsExternalDb(bool $isExternal = false)
88 {
89 $this->isExternalDb = $isExternal;
90 }
91
92 /**
93 * Sets Clone and Table Records
94 * @param null|array $clone
95 * @return bool
96 */
97 public function setData($clone = null): bool
98 {
99 if (!is_array($clone)) {
100 $this->getCloneRecords();
101 } else {
102 $this->clone = (object)$clone;
103 }
104
105 // Set cache file name for the delete cloning job
106 $this->cache->setFilename($this->getJobCacheFileName());
107
108 if (!$this->isExternalDatabase()) {
109 $this->wpdb = WPStaging::getInstance()->get("wpdb");
110 $this->getTableRecords();
111 return true;
112 }
113
114 if ($this->isExternalDatabaseError()) {
115 return false;
116 }
117
118 $this->wpdb = $this->getExternalStagingDb();
119 $this->getTableRecords();
120 return true;
121 }
122
123 /**
124 * Get database object to interact with
125 * @return wpdb
126 */
127 private function getExternalStagingDb(): wpdb
128 {
129 if (!empty($this->clone->databaseSsl) && !defined('MYSQL_CLIENT_FLAGS')) {
130 // phpcs:disable PHPCompatibility.Constants.NewConstants.mysqli_client_ssl_dont_verify_server_certFound
131 define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL | MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT);
132 }
133
134 return new wpdb($this->clone->databaseUser, $this->clone->databasePassword, $this->clone->databaseDatabase, $this->clone->databaseServer);
135 }
136
137 /**
138 * Date database name
139 * @return string
140 */
141 public function getDbName(): string
142 {
143 return (string)$this->wpdb->dbname;
144 }
145
146 /**
147 * Check if external database is used
148 * @return bool
149 */
150 protected function isExternalDatabase(): bool
151 {
152 if (isset($this->isExternalDb)) {
153 return $this->isExternalDb;
154 }
155
156 if (!empty($this->clone->databaseUser) && !empty($this->clone->databasePassword) && !empty($this->clone->databaseDatabase) && !empty($this->clone->databaseServer)) {
157 return true;
158 }
159
160 return false;
161 }
162
163 /**
164 * Get clone
165 * @param null|string $name
166 * @return void
167 */
168 private function getCloneRecords($name = null)
169 {
170 if ($name === null && !isset($_POST["clone"])) {
171 $this->log("Clone name is not set", Logger::TYPE_FATAL);
172 $this->returnException("Clone name is not set");
173 }
174
175 if ($name === null) {
176 $name = $this->sanitize->sanitizeString($_POST["clone"]);
177 }
178
179 $clones = get_option(Sites::STAGING_SITES_OPTION, []);
180
181 if (empty($clones) || !isset($clones[$name])) {
182 $this->log("Couldn't find clone name $name or no existing clone", Logger::TYPE_FATAL);
183 $this->returnException("Couldn't find clone name $name or no existing clone");
184 }
185
186 $this->clone = $clones[$name];
187 $this->clone["name"] = $name;
188
189 $this->clone = (object)$this->clone;
190
191 unset($clones);
192 }
193
194 /**
195 * Get Tables
196 * @return void
197 */
198 private function getTableRecords()
199 {
200 $stagingPrefix = $this->getStagingPrefix();
201
202 // Escape "_" to allow searching for that character
203 $prefix = $this->strings->replaceLastMatch('_', '\_', $stagingPrefix);
204
205 if ($this->isExternalDatabase()) { // Show all tables if its an external database
206 $tables = $this->wpdb->get_results("SHOW TABLE STATUS");
207 } else {
208 $tables = $this->wpdb->get_results("SHOW TABLE STATUS LIKE '$prefix%'");
209 }
210
211 $this->tables = [];
212
213 // no results
214 if ($tables !== null) {
215 foreach ($tables as $table) {
216 $this->tables[] = [
217 "name" => $table->Name,
218 "size" => $this->utilsMath->formatSize($table->Data_length + $table->Index_length),
219 ];
220 }
221 }
222
223 $this->tables = json_decode(json_encode($this->tables));
224 }
225
226 /**
227 * Check and return prefix of the staging site
228 * @return string
229 */
230 private function getStagingPrefix(): string
231 {
232 if ($this->isExternalDatabase() && !empty($this->clone->databasePrefix)) {
233 $this->clone->prefix = $this->clone->databasePrefix;
234 return $this->clone->databasePrefix;
235 }
236
237 // Prefix not defined! Happens if staging site has been generated with older version of wpstg
238 // Try to get staging prefix from wp-config.php of staging site
239 if (empty($this->clone->prefix)) {
240 $path = ABSPATH . $this->clone->directoryName . "/wp-config.php";
241 if (($content = @file_get_contents($path)) === false) {
242 $this->log("Can not open $path. Can't read contents", Logger::TYPE_ERROR);
243 }
244
245 preg_match("/table_prefix\s*=\s*'(\w*)';/", $content, $matches);
246
247 if (!empty($matches[1])) {
248 $this->clone->prefix = $matches[1];
249 } else {
250 $this->returnException("Fatal Error: Can not delete staging site. Can not find Prefix. '$matches[1]'. Stopping for security reasons. Creating a new staging site will likely resolve this the next time. Contact support@wp-staging.com");
251 }
252 }
253
254 if (empty($this->clone->prefix)) {
255 $this->returnException("Fatal Error: Can not delete staging site. Can not find table prefix. Contact support@wp-staging.com");
256 }
257
258 // Check if staging prefix is the same as the live prefix
259 if (empty($this->options->databaseUser) && $this->wpdb->prefix === $this->clone->prefix) {
260 $this->log("Fatal Error: Can not delete staging site. Prefix. '{$this->clone->prefix}' is used for the production site. Stopping for security reasons. Go to Sites > Actions > Edit Data and correct the table prefix or contact us.");
261 $this->returnException("Fatal Error: Can not delete staging site. Prefix. '{$this->clone->prefix}' is used for the production site. Stopping for security reasons. Go to Sites > Actions > Edit Data and correct the table prefix or contact us");
262 }
263
264 return $this->clone->prefix;
265 }
266
267 /**
268 * @return stdClass|false
269 */
270 public function getClone()
271 {
272 return $this->clone;
273 }
274
275 /**
276 * @return null|object
277 */
278 public function getTables()
279 {
280 return $this->tables;
281 }
282
283 /**
284 * Start Module
285 * @param null|array $clone
286 * @return void
287 * @throws CloneNotFoundException
288 * @throws Exception
289 */
290 public function start($clone = null)
291 {
292 // Set data
293 $this->setData($clone);
294
295 // Get the job first
296 $this->getJob();
297
298 $method = "delete" . ucwords($this->job->current);
299
300 if (method_exists($this, $method)) {
301 $this->{$method}();
302 return;
303 }
304
305 // If method doesn't exist probably the cache file was corrupted
306 // Just delete that corrupted cache file and restart itself.
307 $this->cache->delete();
308 $this->start($clone);
309 }
310
311 /**
312 * Get job data
313 * @return void
314 * @throws Exception
315 */
316 public function getJob()
317 {
318 $this->job = $this->cache->get();
319 $this->job = json_decode(json_encode($this->job)); // Convert to object
320
321 if ($this->job !== null && isset($this->job->current)) {
322 return;
323 }
324
325 // Generate JOB
326 $this->job = (object)[
327 "current" => "tables",
328 "nextDirectoryToDelete" => $this->clone->path,
329 "name" => $this->clone->name,
330 ];
331
332 $this->cache->save($this->job);
333 }
334
335 /**
336 * @return bool
337 * @throws Exception
338 */
339 private function updateJob(): bool
340 {
341 $this->job->nextDirectoryToDelete = trim($this->job->nextDirectoryToDelete);
342 $result = $this->cache->save($this->job);
343
344 return $result !== false;
345 }
346
347 /**
348 * @return array
349 */
350 private function getTablesToRemove(): array
351 {
352 $tables = $this->getTableNames();
353
354 if (!isset($_POST["excludedTables"]) || !is_array($_POST["excludedTables"]) || empty($_POST["excludedTables"])) {
355 return $tables;
356 }
357
358 // Sanitize array of table names
359 $sanitizedExcludedTables = $this->sanitize->sanitizeArrayString($_POST["excludedTables"]);
360
361 return array_diff($tables, $sanitizedExcludedTables);
362 }
363
364 /**
365 * @return array
366 */
367 private function getTableNames(): array
368 {
369 return (!is_array($this->tables)) ? [] : array_map(function ($value) {
370 return ($value->name);
371 }, $this->tables);
372 }
373
374 /**
375 * Delete Tables
376 * @return void
377 * @throws Exception
378 * @todo DRY the code by implementing through WPStaging\Framework\Database\TableService::deleteTablesStartWith
379 */
380 public function deleteTables()
381 {
382
383 if ($this->isOverThreshold()) {
384 $this->log("Deleting: Is over threshold");
385 return;
386 }
387
388 $tables = $this->getTablesToRemove();
389
390 foreach ($tables as $table) {
391 // PROTECTION: Never delete any table that begins with wp prefix of live site
392 if (!$this->isExternalDatabase() && $this->strings->startsWith($table, $this->wpdb->prefix)) {
393 $this->log("Fatal Error: Trying to delete table $table of main WP installation!", Logger::TYPE_CRITICAL);
394 }
395
396 $this->wpdb->query("DROP TABLE $table");
397 }
398
399 // Move on to the next
400 $this->job->current = "directory";
401 $this->updateJob();
402 }
403
404 /**
405 * Delete complete directory including all files and sub folders
406 * @return void
407 * @throws Exception
408 */
409 public function deleteDirectory()
410 {
411 if ($this->isFatalError()) {
412 $this->returnException('Can not delete directory: ' . $this->deleteDir . '. This seems to be the root directory. Exclude this directory from deleting and try again.');
413 throw new Exception('Can not delete directory: ' . $this->deleteDir . ' This seems to be the root directory. Exclude this directory from deleting and try again.');
414 }
415
416 // Finished or path does not exist
417 if (
418 empty($this->deleteDir) ||
419 $this->deleteDir === get_home_path() ||
420 !is_dir($this->deleteDir)
421 ) {
422 $this->job->current = "finish";
423 $this->updateJob();
424 $this->deleteFinish();
425 return;
426 }
427
428 $this->log("Delete staging site: " . $this->clone->path);
429
430 // Make sure the root dir is never deleted!
431 if ($this->deleteDir === get_home_path()) {
432 $this->log("Fatal Error 8: Trying to delete root of WP installation!", Logger::TYPE_CRITICAL);
433 $this->returnException('Fatal Error 8: Trying to delete root of WP installation!');
434 }
435
436 // Check if threshold is reached
437 if ($this->isOverThreshold()) {
438 return;
439 }
440
441 $clone = (string)$this->clone->path;
442 $errorMessage = sprintf(__('We could not delete the staging site completely. There are still files in the folder %s that could not be deleted. This could be a write permission issue. Try to delete the folder manually by using FTP or a file manager plugin.<br/> If this happens again please contact us at support@wp-staging.com', 'wp-staging'), $clone);
443 $deleteStatus = self::DELETE_STATUS_FINISHED;
444 $isDeleted = false;
445
446 try {
447 $isDeleted = $this->cleanStagingDirectory($this->deleteDir);
448 } catch (FilesystemExceptions $ex) {
449 $errorMessage = $ex->getMessage();
450 $deleteStatus = self::DELETE_STATUS_UNFINISHED;
451 }
452
453 // If the folder has still not been deleted and there was no exception, we will try again deleting it.
454 if (!$isDeleted && $deleteStatus !== self::DELETE_STATUS_UNFINISHED) {
455 return;
456 }
457
458 // Throw fatal error if the folder has still not been deleted and there are files in it
459 if (!$this->isEmptyDir($this->deleteDir)) {
460 $response = [
461 'job' => 'delete',
462 'status' => true,
463 'delete' => $deleteStatus,
464 'message' => $errorMessage,
465 'error' => true,
466 ];
467 wp_die(json_encode($response));
468 }
469
470 // Successful finish deleting job
471 $this->deleteFinish();
472 }
473
474 /**
475 * @param string $deleteDir
476 * @return bool true if the directory is deleted successfully otherwise false
477 * @throws FilesystemExceptions
478 */
479 protected function cleanStagingDirectory(string $deleteDir): bool
480 {
481 if (!is_dir($deleteDir)) {
482 return true;
483 }
484
485 /** @var Filesystem */
486 $fs = (new Filesystem())
487 ->setShouldStop([$this, 'isOverThreshold'])
488 ->shouldPermissionExceptionsBypass(true)
489 ->setRecursive();
490
491 try {
492 if (!$fs->delete($this->deleteDir)) {
493 return false;
494 }
495 } catch (FilesystemExceptions $ex) {
496 throw $ex;
497 }
498
499 return true;
500 }
501
502 /**
503 * Check if directory exists and is not empty
504 * @param string $dir
505 * @return bool
506 */
507 private function isEmptyDir($dir): bool
508 {
509 if (!is_dir($dir)) {
510 return true;
511 }
512
513 $iterator = new FilesystemIterator($dir);
514
515 return !$iterator->valid();
516 }
517
518 /**
519 * @return bool
520 */
521 public function isFatalError(): bool
522 {
523 $homePath = rtrim(get_home_path(), "/");
524 return $homePath === rtrim($this->deleteDir, "/");
525 }
526
527 /**
528 * Finish / Update Existing Clones
529 * @return void
530 * @throws Exception
531 */
532 public function deleteFinish()
533 {
534 $response = [
535 'delete' => self::DELETE_STATUS_FINISHED,
536 ];
537
538 $existingClones = get_option(Sites::STAGING_SITES_OPTION, []);
539
540 // Check if clone exist and then remove it from options
541 $this->log("Verifying existing clones...");
542 foreach ($existingClones as $name => $clone) {
543 if ($clone["path"] === $this->clone->path) {
544 unset($existingClones[$name]);
545 }
546 }
547
548 if (update_option(Sites::STAGING_SITES_OPTION, $existingClones, false) === false) {
549 $this->log("Delete: Nothing to save.'");
550 }
551
552 // Delete cached file
553 $this->cache->delete();
554 $this->cloneOptionCache->delete();
555
556 wp_die(json_encode($response));
557 }
558
559 /**
560 * Check if there is error in external database connection
561 * can happen if the external database does not exist or stored credentials are wrong
562 * @return bool
563 *
564 * @todo replace it logic with DbInfo once collation check PR is merged.
565 */
566 private function isExternalDatabaseError(): bool
567 {
568 if ($this->clone->databaseSsl) {
569 // wpdb requires this constant for SSL use
570 if (!defined('MYSQL_CLIENT_FLAGS')) {
571 // phpcs:disable PHPCompatibility.Constants.NewConstants.mysqli_client_ssl_dont_verify_server_certFound
572 define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL | MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT);
573 }
574
575 $db = mysqli_init();
576 // @phpstan-ignore-next-line - null is valid for port and socket parameters
577 $db->real_connect($this->clone->databaseServer, $this->clone->databaseUser, $this->clone->databasePassword, $this->clone->databaseDatabase, null, null, MYSQL_CLIENT_FLAGS);
578 } else {
579 $db = new mysqli($this->clone->databaseServer, $this->clone->databaseUser, $this->clone->databasePassword, $this->clone->databaseDatabase);
580 }
581
582 if ($db->connect_error) {
583 return true;
584 }
585
586 return false;
587 }
588
589 /**
590 * Return the cache file which contains the info about current job
591 *
592 * @return string
593 */
594 private function getJobCacheFileName(): string
595 {
596 return "delete_job_{$this->clone->name}";
597 }
598 }
599