PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / MetaSyncDebug.php

MetaSyncDebug.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress trunk, at MetaSyncDebug.php

1,078 lines 37.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Transforms a wp-config.php file.
4 *
5 * @package MetaSync
6 * @since 1.0.0
7 */
8
9 // Prevent direct access
10 if (!defined('ABSPATH')) {
11 exit;
12 }
13
14 /**
15 * Exception thrown when wp-config.php file is missing.
16 */
17 class WPConfigFileNotFoundException extends \Exception
18 {
19 }
20
21 /**
22 * Exception thrown when wp-config.php file is not writable.
23 */
24 class WPConfigFileNotWritableException extends \Exception
25 {
26 }
27
28 /**
29 * Exception thrown when wp-config.php file is empty.
30 */
31 class WPConfigFileEmptyException extends \Exception
32 {
33 }
34
35 /**
36 * Exception thrown when config type is invalid.
37 */
38 class WPConfigInvalidTypeException extends \Exception
39 {
40 }
41
42 /**
43 * Exception thrown when config value is invalid.
44 */
45 class WPConfigInvalidValueException extends \Exception
46 {
47 }
48
49 /**
50 * Exception thrown when placement anchor cannot be located.
51 */
52 class WPConfigAnchorNotFoundException extends \Exception
53 {
54 }
55
56 /**
57 * Exception thrown when normalization fails.
58 */
59 class WPConfigNormalizationException extends \Exception
60 {
61 }
62
63 /**
64 * Exception thrown when saving wp-config.php fails.
65 */
66 class WPConfigSaveException extends \Exception
67 {
68 }
69
70 /**
71 * Transforms a wp-config.php file.
72 */
73 class WPConfigTransformerMetaSync
74 {
75 /**
76 * Append to end of file
77 */
78 const ANCHOR_EOF = 'EOF';
79
80 /**
81 * Age in seconds before a stranded .metasync-tmp-* copy is safe to delete.
82 */
83 const TEMP_MAX_AGE = 300;
84
85 /**
86 * Path to the wp-config.php file.
87 *
88 * @var string
89 */
90 protected $wpConfigPath;
91
92 /**
93 * Original source of the wp-config.php file.
94 *
95 * @var string
96 */
97 protected $wpConfigSrc;
98
99 /**
100 * Array of parsed configs.
101 *
102 * @var array
103 */
104 protected $wpConfigs = [];
105
106 /**
107 * Instantiates the class with a valid wp-config.php.
108 *
109 * @throws WPConfigFileNotFoundException If the wp-config.php file is missing.
110 * @throws WPConfigFileNotWritableException If the wp-config.php file is not writable.
111 *
112 * @param string $wpConfigPath Path to a wp-config.php file.
113 */
114 public function __construct($wpConfigPath)
115 {
116 $basename = basename($wpConfigPath);
117
118 if (!file_exists($wpConfigPath)) {
119 throw new WPConfigFileNotFoundException("{$basename} does not exist.");
120 }
121
122 if (!is_writable($wpConfigPath)) {
123 throw new WPConfigFileNotWritableException("{$basename} is not writable.");
124 }
125
126 $this->wpConfigPath = $wpConfigPath;
127 }
128
129 /**
130 * Checks if a config exists in the wp-config.php file.
131 *
132 * @throws WPConfigFileEmptyException If the wp-config.php file is empty.
133 * @throws WPConfigInvalidTypeException If the requested config type is invalid.
134 *
135 * @param string $type Config type (constant or variable).
136 * @param string $name Config name.
137 *
138 * @return bool
139 */
140 public function exists($type, $name)
141 {
142 $wpConfigSrc = file_get_contents($this->wpConfigPath);
143
144 if (!trim($wpConfigSrc)) {
145 throw new WPConfigFileEmptyException('Config file is empty.');
146 }
147
148 // Normalize the newline to prevent an issue coming from OSX.
149 $this->wpConfigSrc = str_replace(["\n\r", "\r"], "\n", $wpConfigSrc);
150 $this->wpConfigs = $this->parseWpConfig($this->wpConfigSrc);
151
152 if (!isset($this->wpConfigs[$type])) {
153 throw new WPConfigInvalidTypeException("Config type '{$type}' does not exist.");
154 }
155
156 return isset($this->wpConfigs[$type][$name]);
157 }
158
159 /**
160 * Get the value of a config in the wp-config.php file.
161 *
162 * @throws WPConfigFileEmptyException If the wp-config.php file is empty.
163 *
164 * @param string $type Config type (constant or variable).
165 * @param string $name Config name.
166 *
167 * @return mixed|null
168 */
169 public function getValue($type, $name)
170 {
171 $wpConfigSrc = file_get_contents($this->wpConfigPath);
172
173 if (!trim($wpConfigSrc)) {
174 throw new WPConfigFileEmptyException('Config file is empty.');
175 }
176
177 $this->wpConfigSrc = $wpConfigSrc;
178 $this->wpConfigs = $this->parseWpConfig($this->wpConfigSrc);
179
180 if (!isset($this->wpConfigs[$type])) {
181 return null;
182 }
183
184 return $this->wpConfigs[$type][$name]['value'];
185 }
186
187 /**
188 * Adds a config to the wp-config.php file.
189 *
190 * @throws WPConfigAnchorNotFoundException If the config placement anchor could not be located.
191 *
192 * @param string $type Config type (constant or variable).
193 * @param string $name Config name.
194 * @param string $value Config value.
195 * @param array $options Optional. Array of special behavior options.
196 *
197 * @return bool
198 */
199 public function add($type, $name, $value, array $options = [])
200 {
201 if (!is_string($value)) {
202 return false;
203 }
204
205 if ($this->exists($type, $name)) {
206 return false;
207 }
208
209 $defaults = [
210 'raw' => false, // Display value in raw format without quotes.
211 'anchor' => "/* That's all, stop editing!", // Config placement anchor string.
212 'separator' => PHP_EOL, // Separator between config definition and anchor string.
213 'placement' => 'before', // Config placement direction (insert before or after).
214 ];
215
216 list($raw, $anchor, $separator, $placement) = array_values(array_merge($defaults, $options));
217
218 $raw = (bool) $raw;
219 $anchor = (string) $anchor;
220 $separator = (string) $separator;
221 $placement = (string) $placement;
222
223 if (self::ANCHOR_EOF === $anchor) {
224 $contents = $this->wpConfigSrc . $this->normalize($type, $name, $this->formatValue($value, $raw));
225 } else {
226 if (false === strpos($this->wpConfigSrc, $anchor)) {
227 throw new WPConfigAnchorNotFoundException('Unable to locate placement anchor.');
228 }
229
230 $newSrc = $this->normalize($type, $name, $this->formatValue($value, $raw));
231 $newSrc = ('after' === $placement) ? $anchor . $separator . $newSrc : $newSrc . $separator . $anchor;
232 $contents = str_replace($anchor, $newSrc, $this->wpConfigSrc);
233 }
234
235 return $this->save($contents);
236 }
237
238 /**
239 * Updates an existing config in the wp-config.php file.
240 *
241 * @throws WPConfigInvalidValueException If the config value provided is not a string.
242 *
243 * @param string $type Config type (constant or variable).
244 * @param string $name Config name.
245 * @param string $value Config value.
246 * @param array $options Optional. Array of special behavior options.
247 *
248 * @return bool
249 */
250 public function update($type, $name, $value, array $options = [])
251 {
252 if (!is_string($value)) {
253 throw new WPConfigInvalidValueException('Config value must be a string.');
254 }
255
256 $defaults = [
257 'add' => true, // Add the config if missing.
258 'raw' => false, // Display value in raw format without quotes.
259 'normalize' => true, // Normalize config output using WP Coding Standards.
260 ];
261
262 list($add, $raw, $normalize) = array_values(array_merge($defaults, $options));
263
264 $add = (bool) $add;
265 $raw = (bool) $raw;
266 $normalize = (bool) $normalize;
267
268 if (!$this->exists($type, $name)) {
269 return ($add) ? $this->add($type, $name, $value, $options) : false;
270 }
271
272 $oldSrc = $this->wpConfigs[$type][$name]['src'];
273 $oldValue = $this->wpConfigs[$type][$name]['value'];
274 $newValue = $this->formatValue($value, $raw);
275
276 if ($normalize) {
277 $newSrc = $this->normalize($type, $name, $newValue);
278 } else {
279 $newParts = $this->wpConfigs[$type][$name]['parts'];
280 $newParts[1] = str_replace($oldValue, $newValue, $newParts[1]); // Only edit the value part.
281 $newSrc = implode('', $newParts);
282 }
283
284 if ($value === "true") {
285 $contents = preg_replace(
286 sprintf('/(?<=^|;|<\?php\s|<\?\s)(\s*?)%s/m', preg_quote(trim($oldSrc), '/')),
287 '$1' . str_replace('$', '\$', trim($newSrc)),
288 $this->wpConfigSrc
289 );
290 } else {
291 if (!$this->exists($type, $name)) {
292 return $this->save('');
293 }
294
295 $pattern = sprintf('/(?<=^|;|<\?php\s|<\?\s)%s\s*(\S|$)/m', preg_quote($this->wpConfigs[$type][$name]['src'], '/'));
296 $contents = preg_replace($pattern, '$1', $this->wpConfigSrc);
297 }
298
299 return $this->save($contents);
300 }
301
302 /**
303 * Removes a config from the wp-config.php file.
304 *
305 * @param string $type Config type (constant or variable).
306 * @param string $name Config name.
307 *
308 * @return bool
309 */
310 public function remove($type, $name)
311 {
312 if (!$this->exists($type, $name)) {
313 return false;
314 }
315
316 $pattern = sprintf('/(?<=^|;|<\?php\s|<\?\s)%s\s*(\S|$)/m', preg_quote($this->wpConfigs[$type][$name]['src'], '/'));
317 $contents = preg_replace($pattern, '$1', $this->wpConfigSrc);
318
319 return $this->save($contents);
320 }
321
322 /**
323 * Applies formatting to a config value.
324 *
325 * @throws WPConfigInvalidValueException When a raw value is requested for an empty string.
326 *
327 * @param string $value Config value.
328 * @param bool $raw Display value in raw format without quotes.
329 *
330 * @return mixed
331 */
332 protected function formatValue($value, $raw)
333 {
334 if ($raw && '' === trim($value)) {
335 throw new WPConfigInvalidValueException('Raw value for empty string not supported.');
336 }
337
338 return ($raw) ? $value : var_export($value, true);
339 }
340
341 /**
342 * Normalizes the source output for a name/value pair.
343 *
344 * @throws WPConfigNormalizationException If the requested config type does not support normalization.
345 *
346 * @param string $type Config type (constant or variable).
347 * @param string $name Config name.
348 * @param mixed $value Config value.
349 *
350 * @return string
351 */
352 protected function normalize($type, $name, $value)
353 {
354 if ('constant' === $type) {
355 $placeholder = "define( '%s', %s );";
356 } elseif ('variable' === $type) {
357 $placeholder = '$%s = %s;';
358 } else {
359 throw new WPConfigNormalizationException("Unable to normalize config type '{$type}'.");
360 }
361
362 return sprintf($placeholder, $name, $value);
363 }
364
365 /**
366 * Parses the source of a wp-config.php file.
367 *
368 * @param string $src Config file source.
369 *
370 * @return array
371 */
372 protected function parseWpConfig($src)
373 {
374 $configs = [];
375 $configs['constant'] = [];
376 $configs['variable'] = [];
377
378 // Strip comments.
379 foreach (token_get_all($src) as $token) {
380 if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
381 $src = str_replace($token[1], '', $src);
382 }
383 }
384
385 preg_match_all('/(?<=^|;|<\?php\s|<\?\s)(\h*define\s*\(\s*[\'"](\w*?)[\'"]\s*)(,\s*(\'\'|""|\'.*?[^\\\\]\'|".*?[^\\\\]"|.*?)\s*)((?:,\s*(?:true|false)\s*)?\)\s*;)/ims', $src, $constants);
386 preg_match_all('/(?<=^|;|<\?php\s|<\?\s)(\h*\$(\w+)\s*=)(\s*(\'\'|""|\'.*?[^\\\\]\'|".*?[^\\\\]"|.*?)\s*;)/ims', $src, $variables);
387
388 if (!empty($constants[0]) && !empty($constants[1]) && !empty($constants[2]) && !empty($constants[3]) && !empty($constants[4]) && !empty($constants[5])) {
389 foreach ($constants[2] as $index => $name) {
390 $configs['constant'][$name] = [
391 'src' => $constants[0][$index],
392 'value' => $constants[4][$index],
393 'parts' => [
394 $constants[1][$index],
395 $constants[3][$index],
396 $constants[5][$index],
397 ],
398 ];
399 }
400 }
401
402 if (!empty($variables[0]) && !empty($variables[1]) && !empty($variables[2]) && !empty($variables[3]) && !empty($variables[4])) {
403 // Remove duplicate(s), last definition wins.
404 $variables[2] = array_reverse(array_unique(array_reverse($variables[2], true)), true);
405 foreach ($variables[2] as $index => $name) {
406 $configs['variable'][$name] = [
407 'src' => $variables[0][$index],
408 'value' => $variables[4][$index],
409 'parts' => [
410 $variables[1][$index],
411 $variables[3][$index],
412 ],
413 ];
414 }
415 }
416
417 return $configs;
418 }
419
420 /**
421 * Saves new contents to the wp-config.php file via an atomic temp-file + rename() write.
422 *
423 * No persistent backup copy of wp-config.php is ever left in the web root. The new
424 * contents are written to a non-guessable, 0600 temp file alongside the real config
425 * file and then rename()'d over it, which is atomic on the same filesystem. The
426 * original file is never modified until that rename succeeds, so on any failure it is
427 * left intact and no on-disk backup or rollback write is needed.
428 *
429 * Symlinks are resolved first so a symlinked wp-config.php keeps pointing at its real
430 * target instead of being replaced by a regular file, and the original mode and
431 * ownership are reapplied to the newly renamed file.
432 *
433 * @throws WPConfigFileEmptyException If the config file content provided is empty.
434 * @throws WPConfigSaveException If there is a failure when saving the wp-config.php file.
435 *
436 * @param string $contents New config contents.
437 *
438 * @return bool
439 */
440 protected function save($contents)
441 {
442 if (!trim($contents)) {
443 throw new WPConfigFileEmptyException('Cannot save the config file with empty contents.');
444 }
445
446 if ($contents === $this->wpConfigSrc) {
447 return false;
448 }
449
450 // Resolve symlinks and write to the real file. Replacing the path directly
451 // would swap a symlinked wp-config.php for a regular file, which on sites that
452 // deliberately keep the real config outside the web root would drop a full
453 // credentials file into the web root - the opposite of the intent here.
454 // Resolving also keeps the temp file on the same filesystem as the target,
455 // which rename() needs in order to stay atomic.
456 $targetPath = @realpath($this->wpConfigPath);
457 if (false === $targetPath) {
458 $targetPath = $this->wpConfigPath;
459 }
460
461 $dir = dirname($targetPath);
462
463 // The atomic path needs a writable directory, while the constructor only
464 // guarantees a writable file. WordPress' recommended "wp-config.php one level
465 // above the web root" layout commonly has exactly that shape, and earlier
466 // versions wrote fine there, so fall back rather than refusing to save.
467 if (!is_writable($dir)) {
468 // Logged because the fallback gives up atomicity: a crash mid-write can
469 // leave wp-config.php short, so affected hosts should be identifiable.
470 error_log('MetaSync: ' . $dir . ' is not writable; writing wp-config.php in place'
471 . ' (non-atomic) instead of via an atomic replace.');
472 return $this->saveInPlace($targetPath, $contents);
473 }
474
475 // Reap copies stranded by an earlier hard kill. A fatal, OOM or timeout between
476 // fopen() and rename() skips the finally block below and leaves a 0600 copy of
477 // wp-config.php next to it; nothing else would ever remove it.
478 $this->reapStaleTempFiles($dir);
479
480 $originalPerms = @fileperms($targetPath) & 0777;
481 $originalOwner = @fileowner($targetPath);
482 $originalGroup = @filegroup($targetPath);
483
484 // Non-guessable temp path in the same directory so rename() stays atomic.
485 $tempFile = $dir . '/.metasync-tmp-' . bin2hex(random_bytes(8));
486
487 try {
488 // 'x' mode fails if the path already exists, so we never clobber another file.
489 $handle = @fopen($tempFile, 'x');
490 if (false === $handle) {
491 // The directory looked writable but the create failed anyway.
492 return $this->saveInPlace($targetPath, $contents);
493 }
494
495 // Lock the temp file down before any sensitive content is written to it.
496 $permissionsLocked = @chmod($tempFile, 0600);
497 $permissionsWrong = PHP_OS_FAMILY !== 'Windows'
498 && ((@fileperms($tempFile) & 0777) !== 0600);
499 if (!$permissionsLocked || $permissionsWrong) {
500 @fclose($handle);
501 throw new WPConfigSaveException('Could not secure the temporary config file.');
502 }
503
504 $written = @fwrite($handle, $contents);
505 $flushed = @fflush($handle);
506
507 if (function_exists('fsync')) {
508 @fsync($handle);
509 }
510
511 $closed = @fclose($handle);
512 clearstatcache(true, $tempFile);
513
514 // fwrite() can report a full write while the error only surfaces later at
515 // flush or close time (a full disk, NFS, EIO). Confirm the bytes actually
516 // landed before this file is allowed to replace a working wp-config.php.
517 if (
518 false === $written
519 || strlen($contents) !== $written
520 || false === $flushed
521 || false === $closed
522 || @filesize($tempFile) !== strlen($contents)
523 ) {
524 throw new WPConfigSaveException('Failed to update the config file.');
525 }
526
527 // Atomically move the temp file over the config file. The original is only
528 // ever replaced by this single call; on failure it remains untouched.
529 if (!@rename($tempFile, $targetPath)) {
530 throw new WPConfigSaveException('Failed to update the config file.');
531 }
532 } finally {
533 // A thrown failure, fatal or timeout must never leave a readable copy of
534 // wp-config.php sitting next to it.
535 clearstatcache(true, $tempFile);
536 if (is_file($tempFile)) {
537 @unlink($tempFile);
538 }
539 }
540
541 // rename() installs a new inode, so the original ownership and mode do not
542 // carry over. Restore both - ownership best-effort, since only a privileged
543 // process may change it - so the file keeps the identity the host expects.
544 if (PHP_OS_FAMILY !== 'Windows' && false !== $originalOwner && !@chown($targetPath, $originalOwner)) {
545 // Common on hosts where wp-config.php is owned by a deploy user and only
546 // group-writable by the web user: the file is now owned by the web user and
547 // an unprivileged process cannot hand it back. Deploy tooling may lose write
548 // access, so make it findable rather than silent.
549 error_log('MetaSync: wp-config.php is no longer owned by uid ' . $originalOwner
550 . ' after being rewritten, and ownership could not be restored.');
551 }
552
553 if (PHP_OS_FAMILY !== 'Windows' && false !== $originalGroup) {
554 @chgrp($targetPath, $originalGroup);
555 }
556
557 if ($originalPerms) {
558 @chmod($targetPath, $originalPerms);
559 }
560
561 return true;
562 }
563
564 /**
565 * Deletes .metasync-tmp-* files older than the safety window.
566 *
567 * Each one is a full copy of wp-config.php, so they must not accumulate. Only files
568 * older than TEMP_MAX_AGE are touched, so a save running concurrently in another
569 * request never has its temp file pulled out from under it.
570 *
571 * @param string $dir Directory holding the config file.
572 *
573 * @return void
574 */
575 protected function reapStaleTempFiles($dir)
576 {
577 foreach ((array) glob($dir . '/.metasync-tmp-*') as $temp) {
578 if (!is_file($temp)) {
579 continue;
580 }
581
582 $mtime = @filemtime($temp);
583 if (false === $mtime || abs(time() - $mtime) < self::TEMP_MAX_AGE) {
584 continue;
585 }
586
587 @unlink($temp);
588 }
589 }
590
591 /**
592 * Writes the config file in place, keeping its inode, ownership and mode.
593 *
594 * Used when the config file is writable but its directory is not, so the atomic
595 * temp-file + rename() path is unavailable. No second copy of the file is created,
596 * so this does not reintroduce the web-root credential exposure; it trades
597 * atomicity for still working on hosts where the directory cannot be written.
598 *
599 * Because an in-place write can genuinely truncate the file, this is the one path
600 * where restoring the original contents from memory is the correct recovery.
601 *
602 * @throws WPConfigSaveException If the write fails or the file is short afterwards.
603 *
604 * @param string $targetPath Path to the config file.
605 * @param string $contents New config contents.
606 *
607 * @return bool
608 */
609 protected function saveInPlace($targetPath, $contents)
610 {
611 $written = @file_put_contents($targetPath, $contents, LOCK_EX);
612 clearstatcache(true, $targetPath);
613
614 if (
615 false === $written
616 || strlen($contents) !== $written
617 || @filesize($targetPath) !== strlen($contents)
618 ) {
619 // The file may have been left short, so put the known-good source back.
620 if ('' !== trim($this->wpConfigSrc)) {
621 $restored = @file_put_contents($targetPath, $this->wpConfigSrc, LOCK_EX);
622 clearstatcache(true, $targetPath);
623
624 // A false return also fails this comparison, so it covers both cases.
625 if (strlen($this->wpConfigSrc) !== $restored) {
626 // Both the write and the rollback failed, so the file on disk is
627 // very likely truncated and the site will not boot. Say so plainly -
628 // there is deliberately no backup copy to restore from.
629 error_log('MetaSync: wp-config.php may be truncated at ' . $targetPath
630 . ' - restore it manually.');
631 throw new WPConfigSaveException(
632 'Failed to update the config file, and wp-config.php may now be incomplete. Please check it.'
633 );
634 }
635 }
636
637 throw new WPConfigSaveException('Failed to update the config file.');
638 }
639
640 return true;
641 }
642 }
643
644 class ConfigControllerMetaSync
645 {
646 const WPDD_DEBUGGING_PREDEFINED_CONSTANTS_STATE = 'dlct_data_initial';
647 private static $configfilePath;
648
649 protected $optionKey = 'debuglogconfigtool_updated_constant';
650 public $debugConstants = ['WP_DEBUG', 'WP_DEBUG_LOG', 'SCRIPT_DEBUG'];
651 protected $configFileManager;
652
653 /**
654 * Reason wp-config.php cannot be updated, empty string when it can.
655 *
656 * @var string
657 */
658 protected $configError = '';
659
660 /**
661 * True when no anchor could be found to insert new constants after.
662 *
663 * @var bool
664 */
665 protected $anchorMissing = false;
666
667 /**
668 * True when the last store() changed the file but did not apply every constant.
669 *
670 * Each constant is written by its own save(), so a run can land one and fail the
671 * next. Callers must not roll their tracking state back to "off" in that case: the
672 * constants that did land are live, and a state of "off" hides the dashboard widget
673 * and makes the auto-disable cron skip, leaving debug on with nothing to clear it.
674 *
675 * @var bool
676 */
677 protected $partialWrite = false;
678
679 private static $configArgs = [
680 'normalize' => true,
681 'raw' => true,
682 'add' => true,
683 ];
684
685 public function __construct()
686 {
687 $this->initialize();
688 }
689
690 /**
691 * Prepares the config file manager.
692 *
693 * Deliberately never throws. Callers construct this class without a try/catch, so
694 * anything escaping here would be an uncaught fatal that takes the admin page down.
695 * Any problem is recorded instead and every public method degrades gracefully.
696 *
697 * @return void
698 */
699 private function initialize()
700 {
701 self::$configfilePath = $this->getConfigFilePath();
702
703 // $configArgs is static, so clear any anchor a previous instance left behind.
704 // A stale anchor from a differently-shaped config file silently suppresses
705 // writes to this one.
706 unset(self::$configArgs['anchor'], self::$configArgs['placement']);
707 $this->anchorMissing = false;
708
709 if (!is_string(self::$configfilePath) || '' === self::$configfilePath || !file_exists(self::$configfilePath)) {
710 $this->setConfigError('wp-config.php could not be located.');
711 return;
712 }
713
714 // Set anchor for the constants to write
715 $configContents = @file_get_contents(self::$configfilePath);
716 if (!is_string($configContents)) {
717 $this->setConfigError('wp-config.php could not be read.');
718 return;
719 }
720
721 if (false === strpos($configContents, "/* That's all, stop editing!")) {
722 preg_match('@\$table_prefix = (.*);@', $configContents, $matches);
723 $anchor = $matches[0] ?? '';
724
725 // With no anchor, add() cannot position a new constant and silently changes
726 // nothing. Existing constants still update fine, so record it for the
727 // failure message rather than refusing to work at all.
728 $this->anchorMissing = ('' === $anchor);
729
730 self::$configArgs['anchor'] = $anchor;
731 self::$configArgs['placement'] = 'after';
732 }
733
734 if (!is_writable(self::$configfilePath)) {
735 $this->setConfigError('Config file not writable');
736 return;
737 }
738
739 try {
740 $this->configFileManager = new WPConfigTransformerMetaSync(self::$configfilePath);
741 } catch (\Throwable $e) {
742 $this->configFileManager = null;
743 $this->setConfigError($e->getMessage());
744 }
745 }
746
747 /**
748 * Records why wp-config.php cannot be updated and surfaces it in the admin.
749 *
750 * @param string $message Reason to display.
751 *
752 * @return void
753 */
754 protected function setConfigError($message)
755 {
756 $this->configError = (string) $message;
757
758 if (!function_exists('add_action')) {
759 return;
760 }
761
762 $notice = $this->configError;
763 add_action('admin_notices', function () use ($notice) {
764 printf(
765 '<div class="%1$s"><p>%2$s</p></div>',
766 esc_attr('notice notice-error is-dismissible'),
767 esc_html($notice)
768 );
769 });
770 }
771
772 /**
773 * Whether wp-config.php can be updated.
774 *
775 * @return bool
776 */
777 public function isReady()
778 {
779 return $this->configFileManager instanceof WPConfigTransformerMetaSync;
780 }
781
782 /**
783 * Reason wp-config.php cannot be updated, empty string when it can.
784 *
785 * @return string
786 */
787 public function getConfigError()
788 {
789 return $this->configError;
790 }
791
792 /**
793 * Writes the debug constants into wp-config.php.
794 *
795 * @return bool True when the constants were written, false when they could not be.
796 */
797 public function store()
798 {
799 $this->partialWrite = false;
800
801 if (!$this->isReady()) {
802 error_log('MetaSync: skipped wp-config.php debug constants - '
803 . ($this->configError !== '' ? $this->configError : 'the file is not writable.'));
804 return false;
805 }
806
807 $contentsBefore = @file_get_contents(self::$configfilePath);
808
809 try {
810 // Whitelist of allowed constants to prevent arbitrary constant modification
811 $allowedConstants = ['WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY'];
812
813 $updatedConstants = [];
814 $unwritten = [];
815 $wpDebugEnabled = get_option('wp_debug_enabled', 'false');
816 $wpDebugLogEnabled = get_option('wp_debug_log_enabled', 'false');
817 $wpDebugDisplayEnabled = get_option('wp_debug_display_enabled', 'false');
818 $constants = [
819 'WP_DEBUG' => [
820 'name' => 'WP_DEBUG',
821 'value' => ($wpDebugEnabled === 'true' ? true : false),
822 'info' => 'Enable WP_DEBUG mode',
823 ],
824 'WP_DEBUG_LOG' => [
825 'name' => 'WP_DEBUG_LOG',
826 'value' => ($wpDebugLogEnabled === 'true' ? true : false),
827 'info' => 'Enable Debug logging to the /wp-content/debug.log file',
828 ],
829 'WP_DEBUG_DISPLAY' => [
830 'name' => 'WP_DEBUG_DISPLAY',
831 'value' => ($wpDebugDisplayEnabled === 'true' ? true : false),
832 'info' => 'Disable or hide display of errors and warnings in html pages'
833 ]
834 ];
835 $this->maybeRemoveDeletedConstants($constants);
836
837 foreach ($constants as $constant) {
838 // Use sanitize_key instead of sanitize_title for constant names
839 $key = strtoupper(sanitize_key($constant['name']));
840
841 // Whitelist validation - only allow specific constants
842 if (!in_array($key, $allowedConstants, true)) {
843 error_log('MetaSync: Attempted to modify non-whitelisted constant: ' . $key);
844 continue;
845 }
846
847 if (empty($key)) {
848 continue;
849 }
850
851 // Sanitize value - only allow boolean values
852 $value = is_bool($constant['value']) ? $constant['value'] : ($constant['value'] === 'true' || $constant['value'] === true);
853 $value = $value ? 'true' : 'false';
854
855 $this->configFileManager->update('constant', $key, $value, self::$configArgs);
856 $updatedConstants[] = $constant;
857
858 // update() can no-op silently - most notably when no anchor was found, so
859 // a new constant has nowhere to be inserted - so confirm the file really
860 // holds the wanted value rather than assuming the call worked.
861 if (!$this->constantReflectsState($key, $value)) {
862 $unwritten[] = $key;
863 }
864 }
865
866 if (!empty($unwritten)) {
867 // Each constant has its own save(), so an earlier one may already be on
868 // disk. Record that so the caller keeps its state consistent with the file
869 // instead of reporting "off" over live constants.
870 $contentsAfter = @file_get_contents(self::$configfilePath);
871 $this->partialWrite = ($contentsBefore !== $contentsAfter);
872
873 // Bytes only answer "did we change the file". The question that matters is
874 // "is debug live in it": a constant already at the enabling value makes
875 // update() a no-op, so nothing changes on disk yet debug is still on. The
876 // caller must not report "off" over that either.
877 if (!$this->partialWrite) {
878 foreach ($constants as $liveCheck) {
879 $liveKey = strtoupper(sanitize_key($liveCheck['name']));
880 if ($this->constantReflectsState($liveKey, 'true')) {
881 $this->partialWrite = true;
882 break;
883 }
884 }
885 }
886
887 $message = 'wp-config.php was not updated for: ' . implode(', ', $unwritten) . '.';
888 if ($this->anchorMissing) {
889 $message .= ' No place to insert new constants was found in wp-config.php.';
890 }
891 if ($this->partialWrite) {
892 $message .= ' Debug constants are still live in wp-config.php, so it is'
893 . ' partially updated.';
894 }
895
896 error_log('MetaSync: ' . $message);
897 $this->setConfigError($message);
898 return false;
899 }
900
901 return true;
902 } catch (\Throwable $e) {
903 // Throwable rather than Exception: an Error here would otherwise be fatal.
904 // Both callers are ordinary form/REST requests, so report the failure back
905 // to them instead of emitting JSON and dying part-way through the page.
906 $contentsAfter = @file_get_contents(self::$configfilePath);
907 $this->partialWrite = ($contentsBefore !== $contentsAfter);
908
909 // A failed update may happen after an earlier constant was already live, or
910 // after update() no-oped because a constant was already enabled. Bytes alone
911 // cannot distinguish those cases, so keep the caller's state aligned with the
912 // constants that are actually active in wp-config.php.
913 if (!$this->partialWrite) {
914 foreach (['WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY'] as $liveKey) {
915 if ($this->constantReflectsState($liveKey, 'true')) {
916 $this->partialWrite = true;
917 break;
918 }
919 }
920 }
921
922 error_log('MetaSync: Error updating wp-config.php - ' . $e->getMessage());
923 $this->setConfigError('Could not update wp-config.php: ' . $e->getMessage());
924 return false;
925 }
926 }
927
928 /**
929 * Whether the last store() left wp-config.php partially updated.
930 *
931 * @return bool
932 */
933 public function hadPartialWrite()
934 {
935 return $this->partialWrite;
936 }
937
938 /**
939 * Whether wp-config.php now reflects the wanted state for a constant.
940 *
941 * Re-reads the file so the result is what was actually persisted rather than what the
942 * writer was asked to do. Note the writer expresses "off" by removing the constant
943 * rather than defining it false, so an absent constant satisfies a 'false' target.
944 *
945 * @param string $name Constant name.
946 * @param string $expected Expected raw value, 'true' or 'false'.
947 *
948 * @return bool
949 */
950 protected function constantReflectsState($name, $expected)
951 {
952 if (!$this->isReady()) {
953 return false;
954 }
955
956 $wantEnabled = ('true' === strtolower(trim($expected)));
957
958 try {
959 if (!$this->configFileManager->exists('constant', $name)) {
960 return !$wantEnabled;
961 }
962
963 $actual = $this->configFileManager->getValue('constant', $name);
964 } catch (\Throwable $e) {
965 return false;
966 }
967
968 if (!is_scalar($actual)) {
969 return false;
970 }
971
972 return strtolower(trim((string) $actual)) === strtolower(trim($expected));
973 }
974
975 /**
976 * Whether a constant is defined in wp-config.php.
977 *
978 * @param string $constant Constant name.
979 *
980 * @return bool False when wp-config.php cannot be read.
981 */
982 public function exists($constant)
983 {
984 if (!$this->isReady()) {
985 return false;
986 }
987
988 try {
989 return $this->configFileManager->exists('constant', strtoupper($constant));
990 } catch (\Throwable $e) {
991 error_log('MetaSync: Error reading wp-config.php - ' . $e->getMessage());
992 return false;
993 }
994 }
995
996 /**
997 * Reads a constant's value from wp-config.php.
998 *
999 * @param string $constant Constant name.
1000 *
1001 * @return mixed|null Null when absent or wp-config.php cannot be read.
1002 */
1003 public function getValue($constant)
1004 {
1005 if (!$this->isReady()) {
1006 return null;
1007 }
1008
1009 try {
1010 if ($this->configFileManager->exists('constant', strtoupper($constant))) {
1011 return $this->configFileManager->getValue('constant', strtoupper($constant));
1012 }
1013 } catch (\Throwable $e) {
1014 error_log('MetaSync: Error reading wp-config.php - ' . $e->getMessage());
1015 }
1016
1017 return null;
1018 }
1019
1020 /**
1021 * Updates a single constant in wp-config.php.
1022 *
1023 * @param string $key Constant name.
1024 * @param mixed $value Constant value.
1025 *
1026 * @return bool False when the constant could not be written.
1027 */
1028 public function update($key, $value)
1029 {
1030 if (!$this->isReady()) {
1031 return false;
1032 }
1033
1034 try {
1035 // By default, when attempting to update a config that doesn't exist, one will be added.
1036 $option = self::$configArgs;
1037 if (is_bool($value)) {
1038 $value = $value ? 'true' : 'false';
1039 }
1040 return $this->configFileManager->update('constant', strtoupper($key), $value, $option);
1041 } catch (\Throwable $e) {
1042 error_log('MetaSync: Error updating wp-config.php - ' . $e->getMessage());
1043 return false;
1044 }
1045 }
1046
1047 public function getConfigFilePath()
1048 {
1049 $file = ABSPATH . 'wp-config.php';
1050 if (!file_exists($file)) {
1051 if (@file_exists(dirname(ABSPATH) . '/wp-config.php')) {
1052 $file = dirname(ABSPATH) . '/wp-config.php';
1053 }
1054 }
1055 return apply_filters('wp_dlct_config_file_manager_path', $file);
1056 }
1057
1058 /**
1059 * Remove deleted constant from config
1060 *
1061 * @param array $constants Array of constants.
1062 *
1063 * @return void
1064 */
1065 protected function maybeRemoveDeletedConstants($constants)
1066 {
1067 if (!$this->isReady()) {
1068 return;
1069 }
1070
1071 $deletedConstant = array_diff(array_column($constants, 'name'), array_column($constants, 'name'));
1072
1073 foreach ($deletedConstant as $item) {
1074 $this->configFileManager->remove('constant', strtoupper($item));
1075 }
1076 }
1077 }
1078