1, 'WARNING' => 2, 'INFO' => 3, 'DEBUG' => 4, ); /** * Maximum log file size in bytes (5 MB). * * @var int */ const MAX_FILE_SIZE = 5242880; /** * Maximum number of rotated log files. * * @var int */ const MAX_ROTATED_FILES = 3; /** * Log directory path. * * @var string|null */ private static $log_dir = null; /** * Log file path. * * @var string|null */ private static $log_file = null; /** * Debug mode status. * * @var bool|null */ private static $debug_mode = null; /** * Log an error message. * * @since 1.0.0 * @param string $message Log message. * @param array $context Optional context data. * @return bool True on success. */ public static function error( $message, $context = array() ) { return self::log( 'ERROR', $message, $context ); } /** * Log a warning message. * * @since 1.0.0 * @param string $message Log message. * @param array $context Optional context data. * @return bool True on success. */ public static function warning( $message, $context = array() ) { return self::log( 'WARNING', $message, $context ); } /** * Log an info message. * * Only logs to file when debug mode is enabled. * Always sends to error_log for critical info (activation/deactivation). * * @since 1.0.0 * @param string $message Log message. * @param array $context Optional context data. * @param bool $force Force logging even without debug mode (for critical events). * @return bool True on success. */ public static function info( $message, $context = array(), $force = false ) { return self::log( 'INFO', $message, $context, $force ); } /** * Log a debug message (only if debug mode enabled). * * @since 1.0.0 * @param string $message Log message. * @param array $context Optional context data. * @return bool True on success, false if debug mode disabled. */ public static function debug( $message, $context = array() ) { if ( ! self::is_debug_mode() ) { return false; } return self::log( 'DEBUG', $message, $context, true ); } /** * Write a log entry. * * Behavior depends on debug mode: * - Debug OFF: ERROR/WARNING go to error_log() only (WP.org friendly) * - Debug ON: All levels go to custom log file (support-friendly) * * @since 1.0.0 * @param string $level Log level. * @param string $message Log message. * @param array $context Optional context data. * @param bool $force Force file logging even without debug mode. * @return bool True on success. */ private static function log( $level, $message, $context = array(), $force = false ) { $timestamp = current_time( 'Y-m-d H:i:s' ); $log_message = sprintf( '[Agentorie Chat] [%s] [%s] %s', $timestamp, $level, $message ); if ( ! empty( $context ) ) { $log_message .= ' | Context: ' . wp_json_encode( $context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } // Always use error_log for ERROR and WARNING when debug mode is off. // This ensures critical issues are logged somewhere (WP.org friendly). if ( ! self::is_debug_mode() && ! $force ) { if ( in_array( $level, array( 'ERROR', 'WARNING' ), true ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( $log_message ); } return true; } // Debug mode ON or forced: write to custom log file. if ( ! self::ensure_log_directory() ) { // Fallback to error_log if directory creation fails. // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( $log_message ); return false; } self::maybe_rotate_logs(); $log_entry = sprintf( '[%s] [%s] %s', $timestamp, $level, $message ); if ( ! empty( $context ) ) { $log_entry .= ' | Context: ' . wp_json_encode( $context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } $log_entry .= PHP_EOL; $log_file = self::get_log_file_path(); // WP_Filesystem does not support atomic append (FILE_APPEND | LOCK_EX). // Direct file operation required for concurrent-safe log writing. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $result = file_put_contents( $log_file, $log_entry, FILE_APPEND | LOCK_EX ); return false !== $result; } /** * Get the log file path. * * Uses a hashed filename to prevent direct URL guessing. * * @since 1.0.0 * @return string Log file path. */ public static function get_log_file_path() { if ( null === self::$log_file ) { $hash = self::get_log_hash(); self::$log_file = self::get_log_directory() . 'error-' . $hash . '.log'; } return self::$log_file; } /** * Get the log directory path. * * Uses a dedicated directory within the uploads folder as recommended * by WordPress plugin guidelines. The directory is protected with * .htaccess and index files to prevent direct access. * * @since 1.0.0 * @return string Log directory path. */ public static function get_log_directory() { if ( null === self::$log_dir ) { $upload_dir = wp_upload_dir(); self::$log_dir = trailingslashit( $upload_dir['basedir'] ) . 'agentorie-chat/logs/'; } return self::$log_dir; } /** * Get a unique hash for this site's log files. * * This makes log filenames unpredictable to prevent direct access. * * @since 1.0.0 * @return string Unique hash for log filenames. */ private static function get_log_hash() { $hash = get_option( 'agentorie_chat_log_hash' ); if ( empty( $hash ) ) { $hash = wp_generate_password( 16, false ); update_option( 'agentorie_chat_log_hash', $hash, false ); } return $hash; } /** * Ensure the log directory exists. * * Creates the directory with restrictive permissions and protection files. * Note: For Nginx servers, additional server configuration is recommended * to block access to the agentorie-logs directory. * * @since 1.0.0 * @return bool True if directory is writable. */ private static function ensure_log_directory() { $log_dir = self::get_log_directory(); if ( ! file_exists( $log_dir ) ) { if ( ! wp_mkdir_p( $log_dir ) ) { return false; } // Set restrictive permissions (owner read/write only). // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod chmod( $log_dir, 0700 ); // Protect directory from direct access. $filesystem = self::get_filesystem(); if ( $filesystem ) { $filesystem->put_contents( $log_dir . '.htaccess', "Order deny,allow\nDeny from all", FS_CHMOD_FILE ); $filesystem->put_contents( $log_dir . 'index.php', 'put_contents( $log_dir . 'index.html', '', FS_CHMOD_FILE ); } else { // Fallback to direct file operations if WP_Filesystem is unavailable. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Fallback when WP_Filesystem unavailable (FTP credentials required). file_put_contents( $log_dir . '.htaccess', "Order deny,allow\nDeny from all" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Fallback when WP_Filesystem unavailable. file_put_contents( $log_dir . 'index.php', '= 1; $i-- ) { $old_file = $log_dir . 'error-' . $hash . '.' . $i . '.log'; $new_file = $log_dir . 'error-' . $hash . '.' . ( $i + 1 ) . '.log'; if ( file_exists( $old_file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename rename( $old_file, $new_file ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename rename( $log_file, $log_dir . 'error-' . $hash . '.1.log' ); } /** * Get log file size. * * @since 1.0.0 * @return int File size in bytes. */ public static function get_log_file_size() { $log_file = self::get_log_file_path(); if ( ! file_exists( $log_file ) ) { return 0; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_filesize return (int) filesize( $log_file ); } /** * Get formatted log file size. * * @since 1.0.0 * @return string Formatted file size. */ public static function get_formatted_log_file_size() { return size_format( self::get_log_file_size() ); } /** * Get last error timestamp. * * @since 1.0.0 * @return string|null Last error timestamp or null. */ public static function get_last_error_time() { $log_file = self::get_log_file_path(); if ( ! file_exists( $log_file ) ) { return null; } $filesystem = self::get_filesystem(); if ( $filesystem ) { $content = $filesystem->get_contents( $log_file ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Fallback when WP_Filesystem unavailable. $content = file_get_contents( $log_file ); } if ( empty( $content ) ) { return null; } preg_match_all( '/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \[(ERROR|WARNING)\]/', $content, $matches ); if ( empty( $matches[1] ) ) { return null; } return end( $matches[1] ); } /** * Get log contents. * * @since 1.0.0 * @param int $lines Number of lines to return (0 for all). * @return string Log contents. */ public static function get_log_contents( $lines = 0 ) { $log_file = self::get_log_file_path(); if ( ! file_exists( $log_file ) ) { return ''; } $filesystem = self::get_filesystem(); if ( $filesystem ) { $content = $filesystem->get_contents( $log_file ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Fallback when WP_Filesystem unavailable. $content = file_get_contents( $log_file ); } if ( false === $content ) { return ''; } if ( 0 === $lines ) { return $content; } $all_lines = explode( PHP_EOL, $content ); $last_lines = array_slice( $all_lines, -$lines ); return implode( PHP_EOL, $last_lines ); } /** * Download logs. * * Outputs log file as a downloadable text file. * This function should only be called after verifying user permissions * (manage_options capability check in the calling code). * * @since 1.0.0 * @return void */ public static function download_logs() { // Security: Verify user has permission (defense in depth). if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to download logs.', 'agentorie-chat' ) ); } $log_file = self::get_log_file_path(); $filename = 'agentorie-chat-logs-' . gmdate( 'Y-m-d-His' ) . '.log'; // Security: Verify file is within allowed directory. $log_dir = self::get_log_directory(); $real_path = realpath( $log_file ); $real_dir = realpath( $log_dir ); if ( false === $real_path || false === $real_dir || 0 !== strpos( $real_path, $real_dir ) ) { wp_die( esc_html__( 'Invalid log file path.', 'agentorie-chat' ) ); } // Set headers for file download. header( 'Content-Type: text/plain; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '"' ); header( 'Pragma: no-cache' ); header( 'Expires: 0' ); if ( file_exists( $log_file ) && is_readable( $log_file ) ) { // Use readfile() for better memory efficiency with large files. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile readfile( $log_file ); } else { echo esc_html__( 'No logs available.', 'agentorie-chat' ); } exit; } /** * Clear all logs. * * @since 1.0.0 * @return bool True on success. */ public static function clear_logs() { $log_dir = self::get_log_directory(); if ( ! file_exists( $log_dir ) ) { return true; } $files = glob( $log_dir . 'error*.log' ); if ( ! empty( $files ) ) { foreach ( $files as $file ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink if ( ! unlink( $file ) ) { return false; } } } return true; } /** * Check if debug mode enabled. * * @since 1.0.0 * @return bool True if debug mode is enabled. */ public static function is_debug_mode() { if ( null === self::$debug_mode ) { if ( function_exists( 'agentorie_chat_get_setting' ) ) { self::$debug_mode = (bool) agentorie_chat_get_setting( 'debug_mode', false ); } else { self::$debug_mode = false; } } return self::$debug_mode; } /** * Get WP_Filesystem instance. * * Returns the global WP_Filesystem object if available, or null if it * cannot be initialized (e.g., FTP credentials required). * * @since 1.1.4 * @return WP_Filesystem_Base|null Filesystem instance or null. */ private static function get_filesystem() { global $wp_filesystem; if ( $wp_filesystem instanceof WP_Filesystem_Base ) { return $wp_filesystem; } if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } // Pass false to avoid FTP credentials prompt. if ( WP_Filesystem( false ) && $wp_filesystem instanceof WP_Filesystem_Base ) { return $wp_filesystem; } return null; } /** * Clear cache. * * @since 1.0.0 * @return void */ public static function clear_cache() { self::$debug_mode = null; self::$log_dir = null; self::$log_file = null; } /** * PHP error handler. * * @since 1.0.0 * @param int $errno Error number. * @param string $errstr Error message. * @param string $errfile File where error occurred. * @param int $errline Line number. * @return bool False to continue with default error handler. */ public static function error_handler( $errno, $errstr, $errfile, $errline ) { if ( false === strpos( $errfile, 'agentorie-chat' ) ) { return false; } $level = 'ERROR'; switch ( $errno ) { case E_WARNING: case E_USER_WARNING: $level = 'WARNING'; break; case E_NOTICE: case E_USER_NOTICE: $level = 'INFO'; break; case E_DEPRECATED: case E_USER_DEPRECATED: $level = 'DEBUG'; break; } self::log( $level, $errstr, array( 'file' => basename( $errfile ), 'line' => $errline, 'type' => $errno, ) ); return false; } /** * PHP exception handler. * * @since 1.0.0 * @param Throwable $exception The exception. * @return void */ public static function exception_handler( $exception ) { if ( false === strpos( $exception->getFile(), 'agentorie-chat' ) ) { return; } self::error( 'Uncaught exception: ' . $exception->getMessage(), array( 'file' => basename( $exception->getFile() ), 'line' => $exception->getLine(), 'trace' => self::format_trace( $exception->getTraceAsString() ), ) ); } /** * Format trace. * * @since 1.0.0 * @param string $trace Stack trace string. * @return string Formatted trace. */ private static function format_trace( $trace ) { $trace = substr( $trace, 0, 500 ); $trace = preg_replace( '/[A-Za-z]:?[\\/][^\s:]+[\\/]/', '', $trace ); return $trace; } /** * Log activation. * * Always logs (forced) as this is a critical event for debugging. * * @since 1.0.0 * @return void */ public static function log_activation() { self::info( 'Plugin activated', array( 'version' => defined( 'AGENTORIE_CHAT_VERSION' ) ? AGENTORIE_CHAT_VERSION : 'unknown', 'wp_version' => get_bloginfo( 'version' ), 'php' => PHP_VERSION, ), true // Force logging even without debug mode. ); } /** * Log deactivation. * * Always logs (forced) as this is a critical event for debugging. * * @since 1.0.0 * @return void */ public static function log_deactivation() { self::info( 'Plugin deactivated', array( 'version' => defined( 'AGENTORIE_CHAT_VERSION' ) ? AGENTORIE_CHAT_VERSION : 'unknown', ), true // Force logging even without debug mode. ); } }