|null */ private static ?array $levels = null; private string $plugin_slug; /** * Resolved log directory path, or null until it is needed. */ private ?string $log_dir = null; /** * Entries waiting to be written on shutdown. * * @var array */ private array $pending = []; private bool $shutdown_registered = false; /** * Constructor. * * @since 1.0.0 * @param string $plugin_slug Plugin identifier used as a log channel. */ public function __construct(string $plugin_slug = 'ads-destroyer') { $this->plugin_slug = $plugin_slug; } /** * Log a debug message. * * @since 1.0.0 * @param string $message Log message. * @param array $context Additional context data. */ public function debug(string $message, array $context = []): void { $this->log(self::LEVEL_DEBUG, $message, $context); } /** * Log an info message. * * @since 1.0.0 * @param string $message Log message. * @param array $context Additional context data. */ public function info(string $message, array $context = []): void { $this->log(self::LEVEL_INFO, $message, $context); } /** * Log a warning message. * * @since 1.0.0 * @param string $message Log message. * @param array $context Additional context data. */ public function warning(string $message, array $context = []): void { $this->log(self::LEVEL_WARNING, $message, $context); } /** * Log an error message. * * @since 1.0.0 * @param string $message Log message. * @param array $context Additional context data. */ public function error(string $message, array $context = []): void { $this->log(self::LEVEL_ERROR, $message, $context); } /** * Log a message with the specified level. * * @since 1.0.0 * @param string $level Log level. * @param string $message Log message. * @param array $context Additional context data. */ public function log(string $level, string $message, array $context = []): void { if (!$this->is_enabled() || !$this->should_log_level($level)) { return; } $log_entry = $this->format_log_entry($level, $message, $context); $this->pending[] = $log_entry; $this->register_shutdown(); if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WordPress debug log integration error_log($log_entry); } } /** * Write buffered entries to disk. * * @since 1.0.29 */ public function flush(): void { if (empty($this->pending)) { return; } $entries = $this->pending; $this->pending = []; $log_dir = $this->prepare_log_directory(); if ($log_dir === null) { return; } $log_file = $log_dir . '/ads-destroyer-' . gmdate('Y-m-d') . '.log'; if (file_exists($log_file) && filesize($log_file) > self::MAX_FILE_SIZE) { $this->rotate_log_file($log_dir, $log_file); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Plugin log file file_put_contents($log_file, implode(PHP_EOL, $entries) . PHP_EOL, FILE_APPEND | LOCK_EX); $this->maybe_cleanup_old_logs(); } /** * Check whether logging is currently enabled. * * @since 1.0.0 * @return bool True if logging is enabled. */ public function is_enabled(): bool { if (self::$enabled === null) { self::$enabled = function_exists('get_option') ? (bool) get_option(self::OPTION_ENABLED, false) : false; } return self::$enabled; } /** * Enable logging. * * @since 1.0.0 */ public function enable(): void { self::$enabled = true; update_option(self::OPTION_ENABLED, true); } /** * Disable logging. * * @since 1.0.0 */ public function disable(): void { self::$enabled = false; update_option(self::OPTION_ENABLED, false); } /** * Set which log levels should be recorded. * * @since 1.0.0 * @param array $levels Log levels. */ public function set_log_levels(array $levels): void { $levels = array_values(array_intersect($levels, $this->get_available_levels())); self::$levels = $levels; update_option(self::OPTION_LEVELS, $levels); } /** * Get list of log files, newest first. * * @since 1.0.0 * @return array Array of log file paths. */ public function get_log_files(): array { $log_dir = $this->get_log_directory(); $files = glob($log_dir . '/ads-destroyer-*.log'); if (!is_array($files) || empty($files)) { return []; } usort($files, static function (string $a, string $b): int { return filemtime($b) <=> filemtime($a); }); return $files; } /** * Get the most recent log file. * * @since 1.0.0 * @return string|null Latest log file path or null if none exist. */ public function get_latest_log_file(): ?string { $files = $this->get_log_files(); return $files[0] ?? null; } /** * Read the tail of a log file. * * @since 1.0.0 * @param string $file_path Log file path. * @param int $lines Number of lines to read from the end. * @return string Log file content. */ public function read_log_file(string $file_path, int $lines = 1000): string { if (!$this->is_own_log_file($file_path)) { return ''; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Plugin log file $content = (string) file_get_contents($file_path); if ($lines > 0) { $log_lines = explode("\n", $content); $content = implode("\n", array_slice($log_lines, -$lines)); } return $content; } /** * Delete all log files. * * @since 1.0.0 * @return int Number of files deleted. */ public function clear_all_logs(): int { $deleted = 0; foreach ($this->get_log_files() as $file) { if ($this->is_own_log_file($file)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Log file cleanup if (unlink($file)) { $deleted++; } } } return $deleted; } /** * Get the log directory path without creating it. * * The directory name carries a salted suffix so log files cannot be guessed * and downloaded over HTTP. * * @since 1.0.0 * @return string Log directory path. */ public function get_log_directory(): string { if ($this->log_dir !== null) { return $this->log_dir; } $this->log_dir = self::build_log_directory_path(); return $this->log_dir; } /** * Build the log directory path. * * @since 1.0.29 * @return string Log directory path. */ public static function build_log_directory_path(): string { $upload_dir = wp_upload_dir(null, false); $basedir = $upload_dir['basedir'] ?? sys_get_temp_dir(); return $basedir . '/' . self::DIR_PREFIX . '-' . substr(md5(wp_salt('nonce')), 0, 12); } /** * Remove the log directory and every file inside it. * * Used on uninstall and when migrating away from the legacy unprotected * directory. * * @since 1.0.29 * @param string|null $directory Directory to remove. Defaults to the current log directory. */ public static function delete_log_directory(?string $directory = null): void { $directory = $directory ?? self::build_log_directory_path(); if (!is_dir($directory)) { return; } // scandir() is used instead of glob() so the .htaccess guard is included. $entries = scandir($directory); if (is_array($entries)) { foreach ($entries as $entry) { if ($entry === '.' || $entry === '..' || !is_file($directory . '/' . $entry)) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Plugin data cleanup unlink($directory . '/' . $entry); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem is unavailable during uninstall rmdir($directory); } /** * Remove the legacy log directory that was readable over HTTP. * * @since 1.0.29 */ public static function remove_legacy_log_directory(): void { $upload_dir = wp_upload_dir(null, false); $basedir = $upload_dir['basedir'] ?? ''; if ($basedir === '') { return; } self::delete_log_directory($basedir . '/' . self::DIR_PREFIX); } /** * Create the log directory and protect it from direct access. * * @since 1.0.29 * @return string|null Directory path, or null when it is not writable. */ private function prepare_log_directory(): ?string { $log_dir = $this->get_log_directory(); if (is_dir($log_dir)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Logging must not depend on WP_Filesystem credentials return is_writable($log_dir) ? $log_dir : null; } if (!wp_mkdir_p($log_dir)) { return null; } $guards = [ 'index.php' => " "\n\tRequire all denied\n\n\n\tOrder allow,deny\n\tDeny from all\n\n", 'web.config' => "\n\n\t\n\t\t\n\t\t\t\n\t\t\n\t\n\n", ]; foreach ($guards as $filename => $contents) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Protecting the log directory file_put_contents($log_dir . '/' . $filename, $contents); } return $log_dir; } /** * Register the shutdown flush once per instance. * * @since 1.0.29 */ private function register_shutdown(): void { if ($this->shutdown_registered) { return; } $this->shutdown_registered = true; add_action('shutdown', [$this, 'flush'], 999); } /** * Rotate the current log file. * * @since 1.0.0 * @param string $log_dir Log directory. * @param string $log_file Current log file path. */ private function rotate_log_file(string $log_dir, string $log_file): void { $rotated_file = $log_dir . '/ads-destroyer-' . gmdate('Y-m-d_H-i-s') . '.log'; // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Log file rotation rename($log_file, $rotated_file); } /** * Remove old log files, at most once a day. * * @since 1.0.0 */ private function maybe_cleanup_old_logs(): void { if (get_transient(self::CLEANUP_LOCK)) { return; } set_transient(self::CLEANUP_LOCK, 1, DAY_IN_SECONDS); $files = $this->get_log_files(); if (count($files) <= self::MAX_FILES) { return; } foreach (array_slice($files, self::MAX_FILES) as $file) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Log file cleanup unlink($file); } } /** * Check whether a path points to one of our log files. * * @since 1.0.29 * @param string $file_path Path to check. * @return bool True when the file belongs to the plugin log directory. */ private function is_own_log_file(string $file_path): bool { if (!is_file($file_path)) { return false; } $real_path = realpath($file_path); $real_dir = realpath($this->get_log_directory()); if ($real_path === false || $real_dir === false) { return false; } return strpos($real_path, $real_dir . DIRECTORY_SEPARATOR) === 0 && substr($real_path, -4) === '.log'; } /** * Check whether the given level should be recorded. * * @since 1.0.0 * @param string $level Log level to check. * @return bool True if the level should be logged. */ private function should_log_level(string $level): bool { if (self::$levels === null) { $levels = function_exists('get_option') ? get_option(self::OPTION_LEVELS, [self::LEVEL_ERROR]) : [self::LEVEL_ERROR]; self::$levels = is_array($levels) ? $levels : [self::LEVEL_ERROR]; } return in_array($level, self::$levels, true); } /** * Get the list of supported log levels. * * @since 1.0.29 * @return array Supported levels. */ private function get_available_levels(): array { return [self::LEVEL_DEBUG, self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR]; } /** * Format a log entry with timestamp and context. * * @since 1.0.0 * @param string $level Log level. * @param string $message Log message. * @param array $context Additional context data. * @return string Formatted log entry. */ private function format_log_entry(string $level, string $message, array $context): string { $context_str = empty($context) ? '' : ' ' . wp_json_encode($context, JSON_UNESCAPED_UNICODE); // Keep one entry on one line so a message cannot forge additional log records. $message = (string) preg_replace('/[\r\n]+/', ' ', $message); return sprintf( '[%s] %s [%s] %s%s', current_time('Y-m-d H:i:s'), strtoupper($level), $this->plugin_slug, $message, $context_str ); } }