*/ public static function hosts(string ...$urls): array { $hosts = []; foreach ($urls as $url) { $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); $port = wp_parse_url($url, PHP_URL_PORT); if ($host === '' || !preg_match('/^[a-z0-9.-]+$/', $host)) { continue; } $twin = str_starts_with($host, 'www.') ? substr($host, 4) : 'www.' . $host; foreach (filter_var($host, FILTER_VALIDATE_IP) ? [$host] : [$host, $twin] as $name) { if (substr_count($name, '.') === 0 && $name !== 'localhost') { continue; // "www." stripped from a bare name } $hosts[] = $port ? $name . ':' . (int) $port : $name; } } return array_values(array_unique($hosts)); } /** * @param list $hosts * @param array $defines constant => value; names and values are checked, never escaped into shape */ public static function block(array $hosts, array $defines, string $eol = "\n"): string { foreach ($hosts as $host) { if (!preg_match('/^[a-z0-9.-]+(:\d+)?$/', $host)) { throw new WpConfigError(sprintf( /* translators: %s: a host name */ __('The host name %s cannot be written into wp-config.php safely, so nothing was changed.', 'freshet-backup'), $host )); } } if ($hosts === []) { throw new WpConfigError(__('This site\'s address has no host name to limit the connection to, so nothing was written to wp-config.php. Check the Site Address under Settings → General.', 'freshet-backup')); } $lines = [ self::BEGIN, '// Written by Freshet Backup (Settings > Freshet Backup). It applies only on the hosts listed, so a copy', '// of this file elsewhere never backs up into this Dropbox. Disconnect on that screen to remove it.', "if (in_array(strtolower(\$_SERVER['HTTP_HOST'] ?? ''), ['" . implode("', '", $hosts) . "'], true)) {", ]; foreach ($defines as $name => $value) { if (!preg_match('/^FRESHET_BACKUP_[A-Z_]+$/', $name) || !preg_match('/^[A-Za-z0-9._~-]+$/', $value)) { throw new WpConfigError(__('Dropbox returned a value that cannot be written into wp-config.php safely, so nothing was changed. Press Connect Dropbox again.', 'freshet-backup')); } $lines[] = "\tdefine('" . $name . "', '" . $value . "');"; } $lines[] = '}'; $lines[] = self::END; return implode($eol, $lines); } public static function has(string $source): bool { return str_contains($source, self::BEGIN); } /** The file with $block in it: in place of the one already there, or just above the "stop editing" line. */ public static function place(string $source, string $block): string { $eol = self::eol($source); $block = str_replace(["\r\n", "\n"], ["\n", $eol], $block); if (self::has($source)) { self::single($source); return (string) preg_replace_callback(self::BLOCK, static fn (): string => $block, $source, 1); } $at = self::anchor($source); return substr($source, 0, $at) . $block . $eol . $eol . substr($source, $at); } /** The file without the block, and without the blank line placing it added. */ public static function remove(string $source): string { self::single($source); return (string) preg_replace('~^[ \t]*/\* BEGIN Freshet Backup \*/.*?/\* END Freshet Backup \*/[ \t]*(\r?\n)?([ \t]*\r?\n)?~ms', '', $source, 1); } public static function eol(string $source): string { return str_contains($source, "\r\n") ? "\r\n" : "\n"; } public static function parses(string $source): bool { try { token_get_all($source, TOKEN_PARSE); } catch (\ParseError) { return false; } return true; } /** * Replace the file with $source: parse check, write beside it, rename over * it, read it back. Any step that fails leaves the original; a read-back * that differs puts the original back from memory. * * @param (callable(string): (string|false))|null $read how the file is read back; tests stand in a disk that does not keep what it was given */ public static function write(string $path, string $source, ?callable $read = null): void { $read ??= static fn (string $file): string|false => file_get_contents($file); $original = is_file($path) ? file_get_contents($path) : false; if ($original === false) { throw new WpConfigError(__('wp-config.php could not be read, so it was not changed. Check its file permissions.', 'freshet-backup')); } if (!self::parses($source)) { throw new WpConfigError(__('The changed wp-config.php did not pass a syntax check, so the file was left as it was.', 'freshet-backup')); } if (!is_writable($path)) { throw new WpConfigError(__('wp-config.php is not writable by the web server.', 'freshet-backup')); } $mode = fileperms($path) & 0777; $why = self::replace($path, $source, $mode); if ($why !== null) { throw new WpConfigError($why); } clearstatcache(true, $path); if ($read($path) !== $source) { $restored = self::replace($path, $original, $mode) === null; self::forget($path); throw new WpConfigError($restored ? __('wp-config.php did not read back as written, so the previous version was put back.', 'freshet-backup') : __('wp-config.php did not read back as written, and the previous version could not be put back. Check the file by hand, or restore it from your host\'s backup, before the site is loaded again.', 'freshet-backup')); } self::forget($path); } /** * $contents into $path through a temporary file beside it and a rename, so * the file is never half-written. The temporary file never outlives it. * * @return string|null why it could not be done, or null when it was */ private static function replace(string $path, string $contents, int $mode): ?string { $temp = dirname($path) . '/.wp-config.php.freshet-' . bin2hex(random_bytes(6)); if (@file_put_contents($temp, $contents, LOCK_EX) !== strlen($contents)) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a folder we may not write to is an expected answer, reported by the caller. wp_delete_file($temp); return __('The folder that holds wp-config.php is not writable by the web server, so the file was not changed.', 'freshet-backup'); } chmod($temp, $mode); if (!rename($temp, $path)) { wp_delete_file($temp); return __('wp-config.php could not be replaced; it was left as it was.', 'freshet-backup'); } return null; } /** Where the block goes: the start of the "stop editing" line, else of the line that loads wp-settings.php — at the top level only. */ private static function anchor(string $source): int { foreach (['/^[^\n]*stop editing[^\n]*$/mi', '/^[^\n]*wp-settings\.php[^\n]*$/m'] as $pattern) { if (preg_match($pattern, $source, $m, PREG_OFFSET_CAPTURE)) { $at = (int) $m[0][1]; if (!self::topLevel($source, $at)) { throw new WpConfigError(__('The plugin does not edit wp-config.php when its "stop editing" line sits inside a block of code.', 'freshet-backup')); } return $at; } } throw new WpConfigError(__('wp-config.php has neither the "stop editing" line nor the line that loads wp-settings.php, so there is no safe place to add the connection.', 'freshet-backup')); } /** True when $offset starts a token, or sits in whitespace, outside every brace. */ private static function topLevel(string $source, int $offset): bool { $depth = 0; $pos = 0; foreach (token_get_all($source) as $token) { $text = is_array($token) ? $token[1] : $token; $end = $pos + strlen($text); if ($offset === $pos || ($offset > $pos && $offset < $end && is_array($token) && $token[0] === T_WHITESPACE)) { return $depth === 0; } if ($offset < $end) { return false; // inside a comment, a string or inline HTML } if ($text === '{' || (is_array($token) && in_array($token[0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) { $depth++; } elseif ($text === '}') { $depth--; } $pos = $end; } return false; } private static function single(string $source): void { if (substr_count($source, self::BEGIN) !== 1 || substr_count($source, self::END) !== 1 || strpos($source, self::BEGIN) > strpos($source, self::END)) { throw new WpConfigError(__('wp-config.php holds a Freshet Backup block that is incomplete or repeated, so the plugin does not edit it. Fix the lines between the BEGIN and END markers by hand.', 'freshet-backup')); } } /** PHP may keep running the old wp-config.php from its cache for a few seconds otherwise. */ private static function forget(string $path): void { if (function_exists('opcache_invalidate')) { opcache_invalidate($path, true); } } }