OwlCyberSecurity - MANAGER
Edit File: wdev-logger.php
<?php /** * WPMU DEV Logger - A simple logger module * * @version 1.0.2 * @author WPMU DEV (Thobk) * @package WDEV_Logger * * It's created based on Hummingbird\Core\Logger. * This logger lib will handle the old messages based on the expected size. * This means, it will try to get rid of the old messages if the file size is larger than the max size of the log file. * * Uses: * $logger = WDEV_Logger::create(array( * 'max_log_size' => 10,//10MB * 'expected_log_size_in_percent' => 0.7,//70% * 'log_dir' => 'uploads/your_plugin_name', * 'modules' => array( * 'foo' => array( * 'is_private' => true,//-log.php, * 'log_dir' => 'uploads/specific/log_dir', * ), * 'baz' => array( * 'max_log_size' => 5,//5MB * ) * ) * )); * $logger->foo()->error('Log an error into foo module');//[...DATE...] Error: Log an error into foo module. (uploads/specific/log_dir/foo-log.php) * $logger->foo()->warning('...a warning...'); $logger->foo()->notice('...a notice...'); $logger->foo()->info('..info...'); * # Global module: $logger->error('Log an error into the main log file');...(uploads/your_plugin_name/index-debug.log). */ if ( ! defined( 'WP_CONTENT_DIR' ) ) { exit; } if ( ! class_exists( 'WDEV_Logger' ) ) { /** * WPMU DEV Logger */ class WDEV_Logger { /** * Logger error. * * @access private * @var WP_Error|bool $error */ private $error = false; /** * Registered modules. * * @access private * @var array $modules */ private $modules = array(); /** * Current module. * * @access private * * @var string */ private $current_module; /** * Un-lock some limit actions. * Use this to allow some actions which shouldn't call directly. * * @access private * * @var string */ private $un_lock; /** * Nonce name. */ const NONCE_NAME = '_wdevnonce'; /** * Register a new debug log level. * It will have full control. */ const WPMUDEV_DEBUG_LEVEL = 10; /** * Debug level. * * We use constant WP_DEBUG to define the debug level, e.g: * define('WP_DEBUG', LOG_DEBUG ); * * Add backtrace for debug levels: * LOG_ERR or 3 => Only for Error type. * LOG_WARNING or 4 => Only for Warning type. * LOG_NOTICE or 5 => Only for Notice type. * LOG_INFO or 6 => Only for Info type. * LOG_DEBUG or 7 => For Error, Warning and Notice type. * self::WPMUDEV_DEBUG_LEVEL or 10 => for all message types. * * @access private * * @var integer */ private $debug_level; /** * Log level. * * We use constant WP_DEBUG_LOG to define the log level, e.g: * define('WP_DEBUG_LOG', LOG_DEBUG ); * * And by default, we will log all message types. But we can limit it by defining WP_DEBUG_LOG_LOG: * LOG_ERR or 3 => Only log Error type. * LOG_WARNING or 4 => Only log Warning type. * LOG_NOTICE or 5 => Only log Notice type. * LOG_INFO or 6 => Only log Info type. * LOG_DEBUG or 7 => Log Error, Warning and Notice type. * self::WPMUDEV_DEBUG_LEVEL or 10 or TRUE => for all message types. * * @access private * * @var integer */ private $log_level; /** * Default Options. * * @type boolean use_native_filesystem_api * If we can't connect to the Filesystem API, enable this to try to use default PHP functions (WP_Filesystem_Direct). * * @type int max_log_size * Maximum file size for each log file in MB. * Note, set it large might make your site run slower while writing a log or clean the log file. * * @type float expected_log_size_in_percent * Set the expected file log size in percent ( base on $max_log_size ). * E.g. If the log file size is larger than 10MB (15MB) => we will need to reduce (15 - 10 * 70/100 = 8MB). * * @type string log_dir * Log directory, a sub-folder inside WP_CONTENT_DIR * [WP_CONTENT_DIR]/[log_dir] * * @type boolean add_subsite_dir Allow to add sub-site folder in the MU site. * * * Modules: * * By default, we will add a standard module (index), add a empty module to overwrite it. And we can use it to log the general case. * e.g $logger->index()->log('Something for the general case'); * * Module option inherit option from the parent. * These are some new option: * @type boolean is_private * Set is_private is TRUE to use save the log to php file instead of normal .log type. * * Set is_global_module is TRUE to use it as a global/general module. * By default, we will auto register a new global module "index". * With global module we can access the method directly, e.g: * $logger->error('Log an error'); * * Default settings: * array( * 'use_native_filesystem_api' => true, * 'max_log_size' => 10, * 'expected_log_size_in_percent' => 0.7, * 'is_private' => false, * 'log_dir' => 'wpmudev', * 'add_subsite_dir' => true, * 'modules' => array(), * ); * * @access private * * @var array */ private $option; /** * Option key name. * Default is wdev_logger_[plugin_name] * * @access private * @var string */ private $option_key; /** * Return the plugin instance * * @param array $option Logger option. * @param string|null $option_key Option key name. * If $option_key is null we will try to use the plugin folder name instead. * @see self::get_option_key() * @return WDEV_Logger */ public static function create( $option, $option_key = null ) { return new self( $option, $option_key ); } /** * Logger constructor. * * @param array $option Logger option. * @param string $option_key Option key name. */ public function __construct( $option, $option_key ) { $this->option_key = $this->get_option_key( $option_key ); $this->parse_option( $option ); // disable for empty option. if ( ! empty( $option ) ) { add_action( 'wp_ajax_wdev_logger_action', array( $this, 'process_actions' ) ); // Add cron schedule to clean out outdated logs. add_action( 'wdev_logger_clear_logs', array( $this, 'clear_logs' ) ); add_action( 'admin_init', array( $this, 'check_cron_schedule' ) ); } } /** * Set the current module and we can use this to call some actions. * e.g. * $logger->your_module_1()->error('An error.'); * $logger->your_module_1()->notice('A notice.'); * $logger->your_module_2()->delete();//delete the log file. * * @param string $name Method name. * @param array $arguments Arguments. */ public function __call( $name, $arguments ) { if ( $this->set_current_module( $name ) ) { $this->un_lock = true; } elseif ( $this->enabling_debug_log_mode() ) { error_log( sprintf( 'Module "%1$s" does not exists, list of registered modules are ["%2$s"]. Continue with global module "%3$s".', $name, join( '", "', array_keys( $this->modules ) ), $this->option['global_module'] ) );//phpcs:ignore } return $this; } /** * Check debug log mode. * * @return boolean. */ public function enabling_debug_log_mode() { return defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG; } /** * Main logging function. * * @param mixed $message Data to write to log. * @param string|null $type Log type (Error, Warning, Notice, etc). */ public function log( $message, $type = null ) { $this->maybe_active_global_module(); if ( ! $this->should_log( $message, $type ) ) { return; } return $this->write_log_file( $this->format_message( $message, $type ) ); } /** * Format the message to be logged. * * @since 1.0.2 * * @param string $message Message to be logged. * @param string $type Message type. * @return string */ private function format_message( $message, $type ) { if ( ! is_string( $message ) ) { if ( ! is_scalar( $message ) ) { $message = PHP_EOL . print_r( $message, true ); } else { $message = print_r( $message, true ); } } if ( ! empty( $type ) && is_string( $type ) ) { $type = strtolower( $type ); $message = ucfirst( $type ) . ': ' . $message; } $message = '[' . date( 'c' ) . '] ' . $message;//phpcs:ignore // maybe log backtrace. if ( $this->get_debug_level() && is_int( $this->debug_level ) && $this->level_can_do( $this->debug_level, $type ) ) { $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 );//phpcs:ignore $backtrace = array_filter( $backtrace, function( $trace ) { return ! isset( $trace['file'] ) || __FILE__ !== $trace['file'] && false !== strpos( $trace['file'], WP_CONTENT_DIR ); } ); $message .= PHP_EOL .'['. date('c') .'] Stack trace: '. PHP_EOL . print_r( $backtrace, true );//phpcs:ignore } return $message; } /** * Log Error message. * Error: [an error message]. * * @param mixed $message Data to write to log. */ public function error( $message ) { return $this->log( $message, 'Error' ); } /** * Log a notice. * Warning: [a notice message]. * * @param mixed $message Data to write to log. */ public function notice( $message ) { return $this->log( $message, 'Notice' ); } /** * Log a warning. * Warning: [a warning message]. * * @param mixed $message Data to write to log. */ public function warning( $message ) { return $this->log( $message, 'Warning' ); } /** * Log a info. * Info: [a info message]. * * @param mixed $message Data to write to log. */ public function info( $message ) { return $this->log( $message, 'Info' ); } /** * Retrieve download link for a log module. * * @param string $module Module slug. * @return string A nonce url to download the module log. */ public function get_download_link( $module = null ) { // set current module. $this->switch_module( $module ); return wp_nonce_url( add_query_arg( array( 'action' => 'wdev_logger_action', 'log_action' => 'download', 'log_module' => $this->current_module, ), admin_url( 'admin-ajax.php' ) ), $this->get_log_action_name(), self::NONCE_NAME ); } /** * Process logger actions. * * Accepts module name (slug) and action. So far only 'download' and 'delete' actions is supported. */ public function process_actions() { // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( ! isset( $_REQUEST['log_action'], $_REQUEST['log_module'], $_REQUEST[ self::NONCE_NAME ] ) || ! wp_verify_nonce( wp_unslash( $_REQUEST[ self::NONCE_NAME ] ), $this->get_log_action_name() ) ) { // Invalid action, return. return; } // phpcs:enable $action = sanitize_text_field( wp_unslash( $_REQUEST['log_action'] ) ); // Input var ok. $module = sanitize_text_field( wp_unslash( $_REQUEST['log_module'] ) ); // Input var ok. // Not called by a registered module. if ( ! isset( $this->modules[ $module ] ) ) { /* translators: %s Method name */ wp_send_json_error( sprintf( __( 'Module %s does not exist.', 'wpmudev' ), $module ) ); } // Only allow these actions. if ( in_array( $action, array( 'download', 'delete' ), true ) && method_exists( $this, $action ) ) { $should_return = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD']; $result = call_user_func( array( $this, $action ), $module, $should_return ); if ( $should_return ) { wp_send_json_success( $result ); } exit; } /* translators: %s Method name */ wp_send_json_error( sprintf( __( 'Method %s does not exist.', 'wpmudev' ), $action ) ); } /** * Delete current log file. * * @param string $module Module slug. * * @return bool True on success or false on failure. */ public function delete( $module = null ) { if ( ! $this->connect_fs() ) { return false; } global $wp_filesystem; // Set current module. $this->switch_module( $module ); if ( ! $wp_filesystem->exists( $this->get_file() ) ) { return true; } return $wp_filesystem->delete( $this->get_file(), false, 'f' ); } /** * Retrieve option by key. * * @param string $name Key name. * * @return mixed Returns option value. */ public function get_option( $name ) { $this->maybe_active_global_module(); return $this->get_module_option( $name ); } /** * Retrieve current module option by key. * * @access private * * @param string $name Key name. * @param mixed $value Default value. * * @return mixed Returns option value. */ private function get_module_option( $name, $value = null ) { if ( $this->current_module && isset( $this->modules[ $this->current_module ][ $name ] ) ) { $value = $this->modules[ $this->current_module ][ $name ]; } elseif ( isset( $this->option[ $name ] ) ) { $value = $this->option[ $name ]; } return apply_filters( "wdev_logger_get_option_{$name}", $value, $this->current_module, $this->option ); } /** * Clean up the log dir and delete the option. * That's useful to use it while uninstalling plugin. */ public function cleanup() { if ( empty( $this->modules ) || ! $this->connect_fs() ) { return; } foreach ( $this->modules as $module => $module_option ) { $this->delete( $module ); } } /** * Set debug level. * * @param int $debug_level Debug level to set. */ public function set_debug_level( $debug_level = LOG_DEBUG ) { $is_global_settings = ! $this->un_lock; $this->maybe_active_global_module(); return $this->set_level( $debug_level, 'debug_level', $is_global_settings ); } /** * Set log level. * * @param int $log_level Log level to set. */ public function set_log_level( $log_level = LOG_DEBUG ) { $is_global_settings = ! $this->un_lock; $this->maybe_active_global_module(); return $this->set_level( $log_level, 'log_level', $is_global_settings ); } /** * Retrieve log level. * * @access private * * @return int Log level. */ private function get_log_level() { if ( null === $this->log_level ) { $this->log_level = $this->get_module_option( 'log_level', WP_DEBUG_LOG ); } return $this->log_level; } /** * Retrieve debug level. * * @access private * * @return int Debug level. */ private function get_debug_level() { if ( null === $this->debug_level ) { $this->debug_level = $this->get_module_option( 'debug_level', WP_DEBUG ); } return $this->debug_level; } /** * Set level for DEBUG or DEBUG Log. * * @access private * * @param int $level Level to set. * @param string $level_type log_level | debug_level. * @param bool $is_global_settings Set log level for all modules or only specific module. * @return int Current level. */ private function set_level( $level, $level_type, $is_global_settings = false ) { $level = (int) $level; if ( $level > 2 && $level < 8 ) { $level = intval( $level ); } elseif ( $level < 1 ) { $level = 0; } else { $level = self::WPMUDEV_DEBUG_LEVEL; } // If setting level for global module, we will set it in the option, so the module can inherit it. if ( $is_global_settings && $this->current_module === $this->option['global_module'] ) { $this->option[ $level_type ] = $level; $this->{$level_type} = $level; } else { $this->modules[ $this->current_module ][ $level_type ] = $level; $this->{$level_type} = $level; } return $level; } /** * Download logs. * * @access private * * @param string $module Module slug. * @param bool $return Download file or return the content. */ private function download( $module = null, $return = false ) { if ( ! $this->connect_fs() ) { return; } global $wp_filesystem; // Set current module. $this->switch_module( $module ); $content = $wp_filesystem->get_contents( $this->get_file() ); if ( $content && $this->get_module_option( 'is_private' ) ) { $content = ltrim( $content, '<?php die(); ?>' ); } if ( $return ) { return $content; } header( 'Content-Description: WPMUDEV log download' ); header( 'Content-Type: text/plain' ); header( "Content-Disposition: attachment; filename={$this->current_module}.log" ); header( 'Content-Transfer-Encoding: binary' ); header( 'Content-Length: ' . strlen( $content ) ); header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' ); header( 'Expires: 0' ); header( 'Pragma: public' ); echo $content;//phpcs:ignore exit; } /** * Retrieve the action name for form/request. * * @access private * * @return string Action name. */ private function get_log_action_name() { return 'action_' . str_replace( 'wdev_logger_', '', $this->option_key ); } /** * Retrieve option key. * We use this as an option key name to save the parsed option. * * @access private * * @param string $option_key option key name. * @return string Option key name. */ private function get_option_key( $option_key ) { if ( ! $this->option_key ) { if ( empty( $option_key ) ) { list( $option_key ) = explode( '/', ltrim( str_replace( WP_PLUGIN_DIR, '', __FILE__ ), '/' ) ); } $this->option_key = 'wdev_logger_' . $option_key; } return $this->option_key; } /** * Sanitize module slug. * * @since 1.0.2 * * @param string $module_slug Module name/slug. * @return string */ private function sanitize_module_slug( $module_slug ) { return str_replace( '-', '_', sanitize_key( $module_slug ) ); } /** * Parse module option. * * @since 1.0.2 * * @param string $module_slug Sanitized module slug. * @param array $module_option Module option. * @return array Sanitized module option. */ private function parse_module_option( $module_slug, $module_option ) { // If the module_option is empty, we will set it as a general module. if ( ! empty( $module_option['is_global_module'] ) ) { $this->option['global_module'] = $module_slug; } if ( ! empty( $module_option ) ) { // Only keep the allowed keys. $module_option = array_intersect_key( $module_option, $this->option ); array_walk( $module_option, array( $this, 'sanitize_option' ) ); } if ( empty( $module_option['name'] ) ) { $module_option['name'] = str_replace( '_', '-', $module_slug ); } $module_option['name'] = sanitize_title( $module_option['name'] ); return $module_option; } /** * Parse option. * * @access private * * @param array $option Logger option. */ private function parse_option( $option ) { // Default settings. $this->option = array( 'use_native_filesystem_api' => true, 'max_log_size' => 10, 'expected_log_size_in_percent' => 0.7, 'is_private' => false, 'log_dir' => 'wpmudev', 'add_subsite_dir' => true, 'modules' => array(), ); // Parse option, don't parse if the option is empty. if ( empty( $option ) ) { return; } $option = wp_parse_args( $option, $this->option ); if ( empty( $option['modules'] ) ) { // Default module. $option['modules']['index'] = array( 'is_global_module' => 1, ); } $modules = $option['modules']; unset( $option['modules'] ); // Sanitize option. array_walk( $option, array( $this, 'sanitize_option' ) ); $this->option = $option; // Parse modules. $this->parse_modules( $modules ); // Maybe activate the general module. if ( empty( $this->option['global_module'] ) ) { $this->modules['index'] = $this->option; $this->option['global_module'] = 'index'; } // Set current module. $this->current_module = $this->option['global_module']; } /** * Parse option for modules. * * @since 1.0.2 * * @param array $modules List modules to parse. */ private function parse_modules( $modules ) { foreach ( $modules as $module_slug => $module_option ) { if ( empty( $module_slug ) ) { continue; } // Parse module. $this->add_module( $module_slug, $module_option ); } } /** * Sanitize option. * * @access private * * @param mixed $option option value. * @param string $key option key. * @return void */ private function sanitize_option( &$option, $key ) { switch ( $key ) { case 'max_log_size': $option = abs( (int) $option ); return; case 'expected_log_size_in_percent': $option = abs( (float) $option ); if ( $option > 1 ) { $option = 0.9; } return; case 'log_dir': if ( empty( $option ) ) { $option = 'wpmudev'; } else { $option = preg_replace( '#[^a-z0-9_\/\-]#', '', $option ); } return; // We ignore this property to avoid conflict with the cached file name. case 'file_name': // Don't allow to set this option directly, please try to use is_global_module instead. case 'global_module': $option = null; return; } if ( is_bool( $option ) ) { return $option; } elseif ( empty( $option ) ) { if ( isset( $this->option[ $key ] ) ) { $option = $this->option[ $key ]; } else { $option = null; } } elseif ( is_scalar( $option ) ) { $option = sanitize_text_field( $option ); } else { $option = null; } } /** * Retrieve filesystem credentials. * By default, if the access method is not "direct" type, function "request_filesystem_credentials" will return a form * if there is any missing from credentials configs. So we use this custom function to avoid this case. * * @see request_filesystem_credentials() * @link https://developer.wordpress.org/reference/functions/request_filesystem_credentials/ * * @param string $type Access method type: direct | ftpext | ssh2 | ftpsockets. * * @access private * * @return mixed */ private function get_filesystem_credentials( $type ) { if ( 'direct' === $type ) { return true; } $credentials = get_option( 'ftp_credentials', array( 'hostname' => '', 'username' => '', ) ); $ftp_constants = array( 'hostname' => 'FTP_HOST', 'username' => 'FTP_USER', 'password' => 'FTP_PASS', 'public_key' => 'FTP_PUBKEY', 'private_key' => 'FTP_PRIKEY', ); // If defined, set it to that. Else, if POST'd, set it to that. If not, set it to an empty string. // Otherwise, keep it as it previously was (saved details in option). foreach ( $ftp_constants as $key => $constant ) { if ( defined( $constant ) ) { $credentials[ $key ] = constant( $constant ); } elseif ( ! isset( $credentials[ $key ] ) ) { $credentials[ $key ] = ''; } } // Sanitize the hostname, some people might pass in odd data. $credentials['hostname'] = preg_replace( '|\w+://|', '', $credentials['hostname'] ); // Strip any schemes off. if ( strpos( $credentials['hostname'], ':' ) ) { list( $credentials['hostname'], $credentials['port'] ) = explode( ':', $credentials['hostname'], 2 ); if ( ! is_numeric( $credentials['port'] ) ) { unset( $credentials['port'] ); } } else { unset( $credentials['port'] ); } if ( ( defined( 'FTP_SSH' ) && FTP_SSH ) || ( defined( 'FS_METHOD' ) && 'ssh2' === FS_METHOD ) ) { $credentials['connection_type'] = 'ssh'; } elseif ( ( defined( 'FTP_SSL' ) && FTP_SSL ) && 'ftpext' === $type ) { // Only the FTP Extension understands SSL. $credentials['connection_type'] = 'ftps'; } elseif ( ! isset( $credentials['connection_type'] ) ) { // All else fails (and it's not defaulted to something else saved), default to FTP. $credentials['connection_type'] = 'ftp'; } return $credentials; } /** * Connect Filesystem API. * * @access private * * @return int connect status: 0 for failure, 1 for success and -1 for try to use native Filesystem API. */ private function connect_fs() { static $connect_st; if ( null !== $connect_st ) { return $connect_st; } $connect_st = 0; // Need to include file.php for frontend. if ( ! function_exists( 'request_filesystem_credentials' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } // Check if the user has write permissions. $access_type = get_filesystem_method(); if ( empty( $access_type ) ) { $access_type = 'direct'; } // Initialize the Filesystem API. if ( WP_Filesystem( $this->get_filesystem_credentials( $access_type ) ) ) { // Filesystem API is connected, cache result. $connect_st = 1; } else { // Try to use native Filesystem API and log errors. $connect_st = $this->maybe_try_native_fsapi_and_log_error( $access_type ); } return $connect_st; } /** * Maybe try to use native filesystem API for non-direct type, * and log error. * * @since 1.0.2 * * @param string $access_type Access type. * @return int connect status: 0 for failure, and -1 for try to use native Filesystem API. */ private function maybe_try_native_fsapi_and_log_error( $access_type ) { global $wp_filesystem; $connect_st = -1;// Set -1 to allow to use native PHP File. // Try to connect Filesystem API again by using method direct to use the native Filesystem API. if ( 'direct' !== $access_type && $this->get_module_option( 'use_native_filesystem_api' ) ) { if ( $this->enabling_debug_log_mode() && is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors() ) { $error_msg = sprintf( 'Cannot connect to Filesystem API via %1$s: %2$s, trying to use the direct method!', strtoupper( $access_type ), $wp_filesystem->errors->get_error_message() ); } add_filter( 'filesystem_method', array( $this, 'force_access_direct_method' ), 9999 ); if ( ! WP_Filesystem( true ) ) { // This case should be never catch unless file wp-admin/includes/class-wp-filesystem-ftpext.php doesn't exist. $connect_st = 0; $this->error = true; } remove_filter( 'filesystem_method', array( $this, 'force_access_direct_method' ), 9999 ); } else { $connect_st = 0; $this->error = true; } if ( ! $this->enabling_debug_log_mode() ) { // Debug log is disabled, return. return $connect_st; } // Log the error and return. // This is for the case we try to use native PHP File handling. if ( $this->error && empty( $error_msg ) ) { if ( is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors() ) { $error_msg = $wp_filesystem->errors->get_error_message(); } else { /* translators: %s Filesystem method */ $error_msg = sprintf( 'Connect to the Filesystem API via method %s failure!', strtoupper( $access_type ) ); } } if ( ! empty( $error_msg ) ) { error_log( $error_msg );// phpcs:ignore. } return $connect_st; } /** * Force set the filesystem method to direct. * * @usedby hook filesystem_method * @see self::connect_fs() * * @return string Direct method. */ public function force_access_direct_method() { return 'direct'; } /** * Get file name. * * @since 1.0.2 */ private function get_file_name() { // Try to get from cache. $file_name = $this->get_module_option( 'file_name' ); if ( $file_name ) { return $file_name; } // Use PHP for private file. if ( $this->get_module_option( 'is_private' ) ) { $suffix = 'log.php'; } else { $suffix = 'debug.log'; } $file_name = $this->get_module_option( 'name' ); if ( empty( $file_name ) ) { $file_name = $this->current_module; } $file_name = $file_name . '-' . $suffix; // Save to module. $this->modules[ $this->current_module ]['file_name'] = $file_name; return $file_name; } /** * Prepare filename. * * @access private */ private function get_file() { // Try to get from cache. $file = $this->get_log_directory() . $this->get_file_name(); return apply_filters( 'wdev_logger_get_file', $file, $this->current_module, $this->modules[ $this->current_module ] ); } /** * Get log directory. * * @access private * * @return string */ private function get_log_directory() { $log_dir = WP_CONTENT_DIR . '/' . trailingslashit( $this->get_module_option( 'log_dir' ) ); if ( ! is_multisite() ) { return $log_dir; } if ( $this->get_module_option( 'add_subsite_dir' ) ) { $log_dir .= trailingslashit( preg_replace( '#http(s)?://(www.)?#', '', home_url() ) ); } return $log_dir; } /** * Check if module should log or not. * * @param mixed $message Message. * @param string $type Message type: error | warning | notice | info. * * @access private * * @return bool */ private function should_log( $message, $type ) { // We don't log empty message. if ( 0 !== $message && empty( $message ) ) { return false; } // Stop if there is any errors occur. if ( $this->error ) { // Log error. if ( $this->enabling_debug_log_mode() && is_wp_error( $this->error ) ) { error_log( $this->error->get_error_message() );// phpcs:ignore. } return false; } // Make sure we connected to Filesystem API. if ( ! $this->connect_fs() || ! $this->is_writable_log_dir() ) { if ( $this->enabling_debug_log_mode() ) { $error = error_get_last(); if ( ! empty( $error['message'] ) ) { error_log( $error['message'] );// phpcs:ignore. } } return false; } $do_log = $this->get_log_level(); if ( $do_log && is_int( $do_log ) ) { $do_log = $this->level_can_do( $this->log_level, $type, $do_log ); } return apply_filters( 'wdev_logger_should_log', $do_log, $this->current_module, $message ); } /** * Detect if the current log level can do something. * * @access private * * @param int $level Level number, it can be: LOG_ERR | LOG_WARNING | LOG_NOTICE | LOG_INFO | LOG_DEBUG OR self::WPMUDEV_DEBUG_LEVEL. * @param string $type Message type. * @param bool $can_do Default value. * @return bool */ private function level_can_do( $level, $type, $can_do = false ) { $checking_debug_log_level = $can_do; if ( $type && is_string( $type ) ) { $type = strtolower( $type ); switch ( $level ) { case LOG_DEBUG: $can_do = 'error' === $type || 'warning' === $type || 'notice' === $type; break; case LOG_ERR: $can_do = 'error' === $type; break; case LOG_WARNING: $can_do = 'warning' === $type; break; case LOG_NOTICE: $can_do = 'notice' === $type; break; case LOG_INFO: $can_do = 'info' === $type; break; case self::WPMUDEV_DEBUG_LEVEL: $can_do = true; break; } } return apply_filters( 'wdev_logger_level_can_do', $can_do, $level, $type, $checking_debug_log_level ); } /** * Check if log directory is already create, if not - create it. * * @access private * * @return bool */ private function is_writable_log_dir() { global $wp_filesystem; $log_dir = dirname( $this->get_file() ); if ( $wp_filesystem->is_dir( $log_dir ) ) { // The directory exists, check writeable permissions. if ( $wp_filesystem->is_writable( $log_dir ) || $wp_filesystem->chmod( $log_dir, FS_CHMOD_DIR ) ) { return true; } return false; } return $this->create_log_dir( $log_dir ); } /** * Create the log directory. * * @param string $log_dir Log dir. * @return bool True on success, false on failure. */ public function create_log_dir( $log_dir ) { // If we can create nested directories via mkdir, let's do it and return. if ( mkdir( $log_dir, FS_CHMOD_DIR, true ) ) { // Create an index.php file to avoid access log folder directly. global $wp_filesystem; $wp_filesystem->put_contents( $log_dir . '/index.php', '<?php' . PHP_EOL . '// Silence is golden.', FS_CHMOD_FILE ); return true; } return $this->create_nested_directory( $log_dir ); } /** * Try to separate nested log directory and create one by one * if it can be done via mkdir with recursive is TRUE. * * @since 1.0.2 * * @param string $log_dir Log directory. * @return bool */ private function create_nested_directory( $log_dir ) { global $wp_filesystem; $log_dir = str_replace( '\\', '/', $log_dir ); $log_dir = trailingslashit( $log_dir ); $offset = strlen( WP_CONTENT_DIR ); // Detect next slash position from WP_CONTENT_DIR. $next_slash_pos = strpos( $log_dir, '/', $offset ); if ( ! $next_slash_pos ) { // If there is only once depth, create it and return. return $wp_filesystem->mkdir( $log_dir ); } // Try to create nested directories. while ( $next_slash_pos ) { $n_log_dir = substr( $log_dir, 0, $next_slash_pos ); if ( ! $wp_filesystem->is_dir( $n_log_dir ) ) { if ( $wp_filesystem->mkdir( $n_log_dir ) ) { $wp_filesystem->put_contents( $n_log_dir . '/index.php', '<?php' . PHP_EOL . '// Silence is golden.', FS_CHMOD_FILE ); } else { return false; } } $offset = $next_slash_pos + 1; $next_slash_pos = strpos( $log_dir, '/', $offset ); } return true; } /** * Attempt to write file. * * @access private * * @param string $message String to write to file. */ private function write_log_file( $message = '' ) { global $wp_filesystem; // Append a new blank line. $message = trim( $message ) . PHP_EOL; $file = $this->get_file(); // Disable access the private file directly. if ( $this->get_module_option( 'is_private' ) && ! $wp_filesystem->exists( $file ) ) { $message = '<?php die(); ?>' . PHP_EOL . $message; } /** * By default, we will try to append the message to the log file. * Add a filter to allow third-party doing it by their self. */ $check = apply_filters( 'wdev_logger_update_file', null, $file, $message, $this->current_module ); if ( null !== $check ) { return (bool) $check; } // Try to use append method before using put_contents. switch ( $wp_filesystem->method ) { case 'ssh2': $file = $wp_filesystem->sftp_path( $file ); // continue to use direct method. case 'direct': // Append message to the log file. $ret = file_put_contents( $file, $message, FILE_APPEND | LOCK_EX ); if ( strlen( $message ) !== $ret ) { return false; } break; case 'ftpext': if ( function_exists( 'ftp_append' ) ) { $tempfile = wp_tempnam( $file ); $temphandle = fopen( $tempfile, 'wb+' ); if ( ! $temphandle ) { unlink( $tempfile ); return false; } mbstring_binary_safe_encoding(); $data_length = strlen( $message ); $bytes_written = fwrite( $temphandle, $message ); reset_mbstring_encoding(); if ( $data_length !== $bytes_written ) { fclose( $temphandle ); unlink( $tempfile ); return false; } fseek( $temphandle, 0 ); // Skip back to the start of the file being written to. $ret = ftp_append( $wp_filesystem->link, $file, $tempfile, FTP_BINARY ); fclose( $temphandle ); unlink( $tempfile ); break; } // continue to use default option. default: $contents = ''; if ( $wp_filesystem->exists( $file ) ) { $contents = $wp_filesystem->get_contents( $file ); } $contents .= $message; return $wp_filesystem->put_contents( $file, $contents, FS_CHMOD_FILE ); } // chmod file. $wp_filesystem->chmod( $file, FS_CHMOD_FILE ); return $ret; } /** * Set current module. * * @access private * * @param string $module Module name. */ private function set_current_module( $module ) { if ( empty( $module ) || ! isset( $this->modules[ $module ] ) ) { // Module is not exist, return. return 0; } if ( $module === $this->current_module ) { // Is already on this module, return. return -1; } // Set current module. $this->current_module = $module; // Reset debug/log level. $this->log_level = null; $this->debug_level = null; return 1; } /** * Switch module. * * @param string $module Module name. * * @return int 1 if switch is successful, otherwise try to use global module. */ private function switch_module( $module ) { if ( $module ) { $module = str_replace( '-', '_', sanitize_key( $module ) ); } if ( $this->set_current_module( $module ) ) { // Switched to the new module, return. return 1; } // Try to use global module. $this->maybe_active_global_module(); return -1; } /** * Allow call the log actions directly from the original instance. * $logger->error('Something here'); * $logger->log('Something here'); * * @param bool $return Return a instance of WP_Error * or exit if we can't detect the global module. */ public function maybe_active_global_module( $return = false ) { // If is already on existed module, lock it and return. if ( $this->un_lock ) { // re-lock. $this->un_lock = false; return; } // Global module exist, switch to this module, and return. if ( ! empty( $this->option['global_module'] ) && $this->set_current_module( $this->option['global_module'] ) ) { return; } // Return an error. if ( $return ) { return new WP_Error( 'non-registered', 'Cheating, huh?' ); } else { wp_die( 'Cheating, huh?' ); } } /** * Set a schedule to clean the logs. */ public function check_cron_schedule() { if ( ! wp_next_scheduled( 'wdev_logger_clear_logs' ) ) { wp_schedule_event( strtotime( 'midnight' ), 'daily', 'wdev_logger_clear_logs' ); } } /** * Get rid of the old messages that take the file size over the maximum log file size. * We will reduce the file size to the expected size. * Expected size = max_log_size * expected_log_size_in_percent */ public function clear_logs() { if ( empty( $this->modules ) || ! $this->connect_fs() ) { return; } global $wp_filesystem; foreach ( $this->modules as $module => $module_option ) { $this->set_current_module( $module ); $file = $this->get_file(); if ( $wp_filesystem->exists( $file ) ) { // Delete the log file if deactivated debug log. if ( ! $this->get_log_level() ) { $wp_filesystem->delete( $file, false, 'f' ); continue; } $file_size = $wp_filesystem->size( $file ); $max_file_size = $this->get_module_option( 'max_log_size' ) * MB_IN_BYTES; if ( $file_size < $max_file_size ) { continue; } $expected_file_size = $this->get_module_option( 'expected_log_size_in_percent' ) * $max_file_size; if ( $expected_file_size < 1 ) { $wp_filesystem->delete( $file, false, 'f' ); continue; } $contents = $wp_filesystem->get_contents( $file ); $offset = intval( $file_size - $expected_file_size ); $pos = strpos( $contents, PHP_EOL . '[', $offset ); if ( ! $pos ) { $pos = strpos( $contents, PHP_EOL, $offset ); } if ( $pos ) { $contents = substr( $contents, $pos ); if ( $this->get_module_option( 'is_private' ) ) { $contents = '<?php die(); ?>' . $contents; } $wp_filesystem->put_contents( $file, $contents, FS_CHMOD_FILE ); } else { $wp_filesystem->delete( $file, false, 'f' ); } } } } /** * Create nonce for Ajax action 'wdev_logger_action' * * @return string The token */ public function create_nonce() { return wp_create_nonce( $this->get_log_action_name() ); } /** * Update module option for the existed module. * Note: the new option will inherit from old option. * * @param string $module Module slug. * @param array $module_option Module options. * @return bool */ public function update_module( $module, $module_option = array() ) { if ( empty( $module ) ) { return false; } $module_slug = $this->sanitize_module_slug( $module ); // If the module doesn't exist, return. if ( ! isset( $this->modules[ $module_slug ] ) ) { if ( $this->enabling_debug_log_mode() ) { error_log( sprintf( 'Module %s does not exist, use add_module to add a new module.', $module ) );//phpcs:ignore } return false; } $module_option = wp_parse_args( $module_option, $this->modules[ $module_slug ] ); $this->modules[ $module_slug ] = $this->parse_module_option( $module_slug, $module_option ); return true; } /** * Add a new module. * * @uses self::update_module() instead to update module option if it's already exist. * * @param string $module Module slug. * @param array $module_option Module options. * @return bool */ public function add_module( $module, $module_option = array() ) { if ( empty( $module ) ) { return false; } $module_slug = $this->sanitize_module_slug( $module ); // If the module exist, return. if ( isset( $this->modules[ $module_slug ] ) ) { if ( $this->enabling_debug_log_mode() ) { error_log( sprintf( 'Module %s is already exist, use update_module to update new module option.', $module ) );//phpcs:ignore } return false; } $this->modules[ $module_slug ] = $this->parse_module_option( $module_slug, $module_option ); return true; } } }