PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.9.5
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.9.5
4.9.5 4.9.4 4.9.3 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 4 months ago Exceptions 5 years ago Cancel.php 8 months ago CancelUpdate.php 8 months ago Cloning.php 6 days ago CloningProcess.php 6 days ago Data.php 8 months ago Database.php 6 days ago Delete.php 6 days ago Directories.php 5 months ago Files.php 1 month ago Finish.php 6 days ago Job.php 6 days ago JobExecutable.php 8 months ago Logs.php 3 years ago PreserveDataFirstStep.php 2 months ago PreserveDataSecondStep.php 2 months ago ProcessLock.php 1 year ago Scan.php 3 weeks ago SearchReplace.php 6 months ago TotalStepsAreNumberOfTables.php 5 years ago Updating.php 6 days ago
Delete.php
595 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 return $this->externalDatabaseConfiguration->isEnabled($this->clone);
157 }
158
159 /**
160 * Get clone
161 * @param null|string $name
162 * @return void
163 */
164 private function getCloneRecords($name = null)
165 {
166 if ($name === null && !isset($_POST["clone"])) {
167 $this->log("Clone name is not set", Logger::TYPE_FATAL);
168 $this->returnException("Clone name is not set");
169 }
170
171 if ($name === null) {
172 $name = $this->sanitize->sanitizeString($_POST["clone"]);
173 }
174
175 $clones = get_option(Sites::STAGING_SITES_OPTION, []);
176
177 if (empty($clones) || !isset($clones[$name])) {
178 $this->log("Couldn't find clone name $name or no existing clone", Logger::TYPE_FATAL);
179 $this->returnException("Couldn't find clone name $name or no existing clone");
180 }
181
182 $this->clone = $clones[$name];
183 $this->clone["name"] = $name;
184
185 $this->clone = (object)$this->clone;
186
187 unset($clones);
188 }
189
190 /**
191 * Get Tables
192 * @return void
193 */
194 private function getTableRecords()
195 {
196 $stagingPrefix = $this->getStagingPrefix();
197
198 // Escape "_" to allow searching for that character
199 $prefix = $this->strings->replaceLastMatch('_', '\_', $stagingPrefix);
200
201 if ($this->isExternalDatabase()) { // Show all tables if its an external database
202 $tables = $this->wpdb->get_results("SHOW TABLE STATUS");
203 } else {
204 $tables = $this->wpdb->get_results("SHOW TABLE STATUS LIKE '$prefix%'");
205 }
206
207 $this->tables = [];
208
209 // no results
210 if ($tables !== null) {
211 foreach ($tables as $table) {
212 $this->tables[] = [
213 "name" => $table->Name,
214 "size" => $this->utilsMath->formatSize($table->Data_length + $table->Index_length),
215 ];
216 }
217 }
218
219 $this->tables = json_decode(json_encode($this->tables));
220 }
221
222 /**
223 * Check and return prefix of the staging site
224 * @return string
225 */
226 private function getStagingPrefix(): string
227 {
228 if ($this->isExternalDatabase() && !empty($this->clone->databasePrefix)) {
229 $this->clone->prefix = $this->clone->databasePrefix;
230 return $this->clone->databasePrefix;
231 }
232
233 // Prefix not defined! Happens if staging site has been generated with older version of wpstg
234 // Try to get staging prefix from wp-config.php of staging site
235 if (empty($this->clone->prefix)) {
236 $path = ABSPATH . $this->clone->directoryName . "/wp-config.php";
237 if (($content = @file_get_contents($path)) === false) {
238 $this->log("Can not open $path. Can't read contents", Logger::TYPE_ERROR);
239 }
240
241 preg_match("/table_prefix\s*=\s*'(\w*)';/", $content, $matches);
242
243 if (!empty($matches[1])) {
244 $this->clone->prefix = $matches[1];
245 } else {
246 $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");
247 }
248 }
249
250 if (empty($this->clone->prefix)) {
251 $this->returnException("Fatal Error: Can not delete staging site. Can not find table prefix. Contact support@wp-staging.com");
252 }
253
254 // Check if staging prefix is the same as the live prefix
255 if (empty($this->options->databaseUser) && $this->wpdb->prefix === $this->clone->prefix) {
256 $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.");
257 $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");
258 }
259
260 return $this->clone->prefix;
261 }
262
263 /**
264 * @return stdClass|false
265 */
266 public function getClone()
267 {
268 return $this->clone;
269 }
270
271 /**
272 * @return null|object
273 */
274 public function getTables()
275 {
276 return $this->tables;
277 }
278
279 /**
280 * Start Module
281 * @param null|array $clone
282 * @return void
283 * @throws CloneNotFoundException
284 * @throws Exception
285 */
286 public function start($clone = null)
287 {
288 // Set data
289 $this->setData($clone);
290
291 // Get the job first
292 $this->getJob();
293
294 $method = "delete" . ucwords($this->job->current);
295
296 if (method_exists($this, $method)) {
297 $this->{$method}();
298 return;
299 }
300
301 // If method doesn't exist probably the cache file was corrupted
302 // Just delete that corrupted cache file and restart itself.
303 $this->cache->delete();
304 $this->start($clone);
305 }
306
307 /**
308 * Get job data
309 * @return void
310 * @throws Exception
311 */
312 public function getJob()
313 {
314 $this->job = $this->cache->get();
315 $this->job = json_decode(json_encode($this->job)); // Convert to object
316
317 if ($this->job !== null && isset($this->job->current)) {
318 return;
319 }
320
321 // Generate JOB
322 $this->job = (object)[
323 "current" => "tables",
324 "nextDirectoryToDelete" => $this->clone->path,
325 "name" => $this->clone->name,
326 ];
327
328 $this->cache->save($this->job);
329 }
330
331 /**
332 * @return bool
333 * @throws Exception
334 */
335 private function updateJob(): bool
336 {
337 $this->job->nextDirectoryToDelete = trim($this->job->nextDirectoryToDelete);
338 $result = $this->cache->save($this->job);
339
340 return $result !== false;
341 }
342
343 /**
344 * @return array
345 */
346 private function getTablesToRemove(): array
347 {
348 $tables = $this->getTableNames();
349
350 if (!isset($_POST["excludedTables"]) || !is_array($_POST["excludedTables"]) || empty($_POST["excludedTables"])) {
351 return $tables;
352 }
353
354 // Sanitize array of table names
355 $sanitizedExcludedTables = $this->sanitize->sanitizeArrayString($_POST["excludedTables"]);
356
357 return array_diff($tables, $sanitizedExcludedTables);
358 }
359
360 /**
361 * @return array
362 */
363 private function getTableNames(): array
364 {
365 return (!is_array($this->tables)) ? [] : array_map(function ($value) {
366 return ($value->name);
367 }, $this->tables);
368 }
369
370 /**
371 * Delete Tables
372 * @return void
373 * @throws Exception
374 * @todo DRY the code by implementing through WPStaging\Framework\Database\TableService::deleteTablesStartWith
375 */
376 public function deleteTables()
377 {
378
379 if ($this->isOverThreshold()) {
380 $this->log("Deleting: Is over threshold");
381 return;
382 }
383
384 $tables = $this->getTablesToRemove();
385
386 foreach ($tables as $table) {
387 // PROTECTION: Never delete any table that begins with wp prefix of live site
388 if (!$this->isExternalDatabase() && $this->strings->startsWith($table, $this->wpdb->prefix)) {
389 $this->log("Fatal Error: Trying to delete table $table of main WP installation!", Logger::TYPE_CRITICAL);
390 }
391
392 $this->wpdb->query("DROP TABLE $table");
393 }
394
395 // Move on to the next
396 $this->job->current = "directory";
397 $this->updateJob();
398 }
399
400 /**
401 * Delete complete directory including all files and sub folders
402 * @return void
403 * @throws Exception
404 */
405 public function deleteDirectory()
406 {
407 if ($this->isFatalError()) {
408 $this->returnException('Can not delete directory: ' . $this->deleteDir . '. This seems to be the root directory. Exclude this directory from deleting and try again.');
409 throw new Exception('Can not delete directory: ' . $this->deleteDir . ' This seems to be the root directory. Exclude this directory from deleting and try again.');
410 }
411
412 // Finished or path does not exist
413 if (
414 empty($this->deleteDir) ||
415 $this->deleteDir === get_home_path() ||
416 !is_dir($this->deleteDir)
417 ) {
418 $this->job->current = "finish";
419 $this->updateJob();
420 $this->deleteFinish();
421 return;
422 }
423
424 $this->log("Delete staging site: " . $this->clone->path);
425
426 // Make sure the root dir is never deleted!
427 if ($this->deleteDir === get_home_path()) {
428 $this->log("Fatal Error 8: Trying to delete root of WP installation!", Logger::TYPE_CRITICAL);
429 $this->returnException('Fatal Error 8: Trying to delete root of WP installation!');
430 }
431
432 // Check if threshold is reached
433 if ($this->isOverThreshold()) {
434 return;
435 }
436
437 $clone = (string)$this->clone->path;
438 $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);
439 $deleteStatus = self::DELETE_STATUS_FINISHED;
440 $isDeleted = false;
441
442 try {
443 $isDeleted = $this->cleanStagingDirectory($this->deleteDir);
444 } catch (FilesystemExceptions $ex) {
445 $errorMessage = $ex->getMessage();
446 $deleteStatus = self::DELETE_STATUS_UNFINISHED;
447 }
448
449 // If the folder has still not been deleted and there was no exception, we will try again deleting it.
450 if (!$isDeleted && $deleteStatus !== self::DELETE_STATUS_UNFINISHED) {
451 return;
452 }
453
454 // Throw fatal error if the folder has still not been deleted and there are files in it
455 if (!$this->isEmptyDir($this->deleteDir)) {
456 $response = [
457 'job' => 'delete',
458 'status' => true,
459 'delete' => $deleteStatus,
460 'message' => $errorMessage,
461 'error' => true,
462 ];
463 wp_die(json_encode($response));
464 }
465
466 // Successful finish deleting job
467 $this->deleteFinish();
468 }
469
470 /**
471 * @param string $deleteDir
472 * @return bool true if the directory is deleted successfully otherwise false
473 * @throws FilesystemExceptions
474 */
475 protected function cleanStagingDirectory(string $deleteDir): bool
476 {
477 if (!is_dir($deleteDir)) {
478 return true;
479 }
480
481 /** @var Filesystem */
482 $fs = (new Filesystem())
483 ->setShouldStop([$this, 'isOverThreshold'])
484 ->shouldPermissionExceptionsBypass(true)
485 ->setRecursive();
486
487 try {
488 if (!$fs->delete($this->deleteDir)) {
489 return false;
490 }
491 } catch (FilesystemExceptions $ex) {
492 throw $ex;
493 }
494
495 return true;
496 }
497
498 /**
499 * Check if directory exists and is not empty
500 * @param string $dir
501 * @return bool
502 */
503 private function isEmptyDir($dir): bool
504 {
505 if (!is_dir($dir)) {
506 return true;
507 }
508
509 $iterator = new FilesystemIterator($dir);
510
511 return !$iterator->valid();
512 }
513
514 /**
515 * @return bool
516 */
517 public function isFatalError(): bool
518 {
519 $homePath = rtrim(get_home_path(), "/");
520 return $homePath === rtrim($this->deleteDir, "/");
521 }
522
523 /**
524 * Finish / Update Existing Clones
525 * @return void
526 * @throws Exception
527 */
528 public function deleteFinish()
529 {
530 $response = [
531 'delete' => self::DELETE_STATUS_FINISHED,
532 ];
533
534 $existingClones = get_option(Sites::STAGING_SITES_OPTION, []);
535
536 // Check if clone exist and then remove it from options
537 $this->log("Verifying existing clones...");
538 foreach ($existingClones as $name => $clone) {
539 if ($clone["path"] === $this->clone->path) {
540 unset($existingClones[$name]);
541 }
542 }
543
544 if (update_option(Sites::STAGING_SITES_OPTION, $existingClones, false) === false) {
545 $this->log("Delete: Nothing to save.'");
546 }
547
548 // Delete cached file
549 $this->cache->delete();
550 $this->cloneOptionCache->delete();
551
552 wp_die(json_encode($response));
553 }
554
555 /**
556 * Check if there is error in external database connection
557 * can happen if the external database does not exist or stored credentials are wrong
558 * @return bool
559 *
560 * @todo replace it logic with DbInfo once collation check PR is merged.
561 */
562 private function isExternalDatabaseError(): bool
563 {
564 if ($this->clone->databaseSsl) {
565 // wpdb requires this constant for SSL use
566 if (!defined('MYSQL_CLIENT_FLAGS')) {
567 // phpcs:disable PHPCompatibility.Constants.NewConstants.mysqli_client_ssl_dont_verify_server_certFound
568 define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL | MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT);
569 }
570
571 $db = mysqli_init();
572 // @phpstan-ignore-next-line - null is valid for port and socket parameters
573 $db->real_connect($this->clone->databaseServer, $this->clone->databaseUser, $this->clone->databasePassword, $this->clone->databaseDatabase, null, null, MYSQL_CLIENT_FLAGS);
574 } else {
575 $db = new mysqli($this->clone->databaseServer, $this->clone->databaseUser, $this->clone->databasePassword, $this->clone->databaseDatabase);
576 }
577
578 if ($db->connect_error) {
579 return true;
580 }
581
582 return false;
583 }
584
585 /**
586 * Return the cache file which contains the info about current job
587 *
588 * @return string
589 */
590 private function getJobCacheFileName(): string
591 {
592 return "delete_job_{$this->clone->name}";
593 }
594 }
595