log_path = trailingslashit( PYS_FREE_PATH ).'logs/'; } public function init() { $this->isEnabled = PYS()->getOption('pys_logs_enable'); // Migrate old log files to new randomized names (one-time operation) $this->maybe_migrate_log_files(); // Always ensure protection files exist, regardless of logging being enabled // This prevents PII exposure even if logs were created before and logging is now disabled $this->create_protection_files(); } /** * Get or generate random suffix for log file names * This suffix is stored in WordPress options and persists across requests * * @return string Random suffix (32 characters hex string) */ public static function get_log_suffix() { $suffix = get_option( self::LOG_SUFFIX_OPTION ); if ( empty( $suffix ) ) { $suffix = self::generate_random_suffix(); update_option( self::LOG_SUFFIX_OPTION, $suffix, false ); } return $suffix; } /** * Generate a cryptographically secure random suffix * * @return string Random suffix (32 characters hex string) */ protected static function generate_random_suffix() { if ( function_exists( 'wp_generate_password' ) ) { // Use WordPress function for better compatibility return wp_hash( wp_generate_password( 32, true, true ) . time() . wp_rand() ); } // Fallback to PHP random_bytes return bin2hex( random_bytes( 16 ) ); } /** * Migrate old log files to new randomized names * This runs once when the suffix doesn't exist yet * * @return void */ protected function maybe_migrate_log_files() { // Check if migration is needed (suffix option doesn't exist) $suffix = get_option( self::LOG_SUFFIX_OPTION ); if ( ! empty( $suffix ) ) { // Already migrated return; } // Generate new suffix $new_suffix = self::generate_random_suffix(); update_option( self::LOG_SUFFIX_OPTION, $new_suffix, false ); // Migrate existing log files $this->migrate_log_file( 'pys_debug.log', 'pys_debug_' . $new_suffix . '.log' ); } /** * Rename a log file from old name to new name * * @param string $old_name Old file name * @param string $new_name New file name * @return bool True if renamed successfully or file didn't exist */ protected function migrate_log_file( $old_name, $new_name ) { $old_path = $this->log_path . $old_name; $new_path = $this->log_path . $new_name; if ( file_exists( $old_path ) && is_writable( $old_path ) ) { return @rename( $old_path, $new_path ); } return true; } /** * Destructor. * * Cleans up open file handles. */ public function __destruct() { if ( is_resource( $this->handle ) ) { fclose( $this->handle ); // @codingStandardsIgnoreLine. } } public function debug($message,$args = null) { $this->log('debug',$message,$args); } public function error($message,$args = null) { $this->log('error',$message,$args); } protected function log($level,$message,$args = null) { if(!$this->isEnabled) return; if($args) { $message .= " \nArgs: ".print_r($args,true); } $this->handle(time(),$level,$message,[]); } /** * Handle a log entry. * * @param int $timestamp Log timestamp. * @param string $level emergency|alert|critical|error|warning|notice|info|debug. * @param string $message Log message. * @param array $context { * Additional information for log handlers. * } * * @return bool False if value was not handled and true if value was handled. */ protected function handle( $timestamp, $level, $message, $context ) { $time_string = date( 'c', $timestamp ); $entry = "{$time_string} {$level} {$message}"; return $this->add( $entry ); } /** * Open log file for writing. * * @param string $mode Optional. File mode. Default 'a'. * @return bool Success. */ protected function open( $mode = 'a' ) { if ( $this->is_open() ) { return true; } $file = static::get_log_file_path( ); if ( $file ) { if ( ! file_exists( $file ) ) { if( !is_dir( $this->log_path ) ) { if (!mkdir($concurrentDirectory = $this->log_path, 0755, true) && !is_dir($concurrentDirectory)) { throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); } } $temphandle = @fopen( $file, 'w+' ); // @codingStandardsIgnoreLine. if ( is_resource( $temphandle ) ) { @fclose( $temphandle ); // @codingStandardsIgnoreLine. if ( ! defined( 'FS_CHMOD_FILE' ) ) { define( 'FS_CHMOD_FILE', 0644 ); } @chmod( $file, FS_CHMOD_FILE ); // @codingStandardsIgnoreLine. } } $resource = @fopen( $file, $mode ); // @codingStandardsIgnoreLine. if ( $resource ) { $this->handle = $resource; return true; } } return false; } /** * Create protection files (.htaccess and index.php) in logs directory * to prevent direct access to log files * * @return void */ protected function create_protection_files() { if ( ! is_dir( $this->log_path ) ) { return; } // Create .htaccess file to deny access $htaccess_file = $this->log_path . '.htaccess'; if ( ! file_exists( $htaccess_file ) ) { $htaccess_content = "# Deny access to all files in this directory\n"; $htaccess_content .= "# Apache 2.4+\n"; $htaccess_content .= "\n"; $htaccess_content .= " Require all denied\n"; $htaccess_content .= "\n\n"; $htaccess_content .= "# Apache 2.2\n"; $htaccess_content .= "\n"; $htaccess_content .= " Deny from all\n"; $htaccess_content .= "\n"; @file_put_contents( $htaccess_file, $htaccess_content ); @chmod( $htaccess_file, 0644 ); } // Create index.php file to prevent directory listing $index_file = $this->log_path . 'index.php'; if ( ! file_exists( $index_file ) ) { $index_content = "handle ); } /** * Close a handle. * * @return bool success */ protected function close() { $result = false; if ( $this->is_open() ) { $result = fclose( $this->handle ); // @codingStandardsIgnoreLine. $this->handle = null; } return $result; } /** * Add a log entry to chosen file. * * @param string $entry Log entry text. * * @return bool True if write was successful. */ protected function add( $entry ) { $result = false; if ( $this->open() && is_resource( $this->handle ) ) { $result = fwrite( $this->handle, $entry . PHP_EOL ); // @codingStandardsIgnoreLine. } return false !== $result; } public function getLogs( ) { if(is_file( static::get_log_file_path() )) return file_get_contents(static::get_log_file_path()); return ""; } /** * Remove/delete the chosen file. * * @return bool */ public function remove( ) { if ( ! current_user_can( 'manage_pys' ) ) { return; } $removed = false; $file = realpath($this::get_log_file_path()); if (is_file($file) && is_writable($file)) { // phpcs:ignore WordPress.VIP.FileSystemWritesDisallow.file_ops_is_writable $this->close(); // Close first to be certain no processes keep it alive after it is unlinked. $removed = unlink($file); // phpcs:ignore WordPress.VIP.FileSystemWritesDisallow.file_ops_unlink } return $removed; } }