*/ private $default_context = array(); /** * Log file path (fallback only). * * @var string */ private $path = ''; /** * Channel label merged into fallback file lines. * * @var string */ private $channel = 'afromewo'; /** * Per-request identifier. * * @var string */ private $request_id = ''; /** * Cached WC logger instance. * * @var \WC_Logger_Interface|null */ private $wc_logger; /** * Whether we already resolved the WC logger (avoids repeated wc_get_logger calls). * * @var bool */ private $wc_logger_resolved = false; /** * Get singleton instance. * * @return Logger */ public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Constructor. */ private function __construct() { $this->request_id = function_exists( 'wp_generate_uuid4' ) ? (string) wp_generate_uuid4() : (string) uniqid( 'afromewo_', true ); $this->path = $this->default_path(); } /** * Set log file path (fallback when WC logger is not used). * * @param string $path Absolute file path. * @return void */ public function set_path( $path ) { $path = (string) $path; if ( '' !== $path && $this->is_path_in_log_directory( $path ) ) { $this->path = $path; } } /** * Set channel name (fallback file lines only). * * @param string $channel Channel name. * @return void */ public function set_channel( $channel ) { $channel = trim( (string) $channel ); if ( '' !== $channel ) { $this->channel = $channel; } } /** * Merge default context into every call (Laravel-like). * * @param array $context Context to merge. * @return void */ public function with_context( $context ) { $this->default_context = array_merge( $this->default_context, (array) $context ); } /** * Log with a given level. * * @param string $level Log level. * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function log( $level, $message, $context = array() ) { $level = $this->normalize_wc_level( (string) $level ); $message = $this->stringify_message( $message ); // Opt-in must be checked before any logging side effects (WooCommerce guideline). if ( ! $this->is_opt_in_enabled() ) { return; } if ( ! $this->passes_min_level( $level ) ) { return; } $context = array_merge( $this->default_context, (array) $context ); $context = $this->enrich_context( $context ); $context = $this->redact_context( $context ); $interpolated = $this->interpolate( $message, $context ); $this->dispatch( $level, $interpolated, $context ); } /** * Convenience methods (PSR-3 style). * * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function debug( $message, $context = array() ) { $this->log( 'debug', $message, $context ); } /** * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function info( $message, $context = array() ) { $this->log( 'info', $message, $context ); } /** * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function notice( $message, $context = array() ) { $this->log( 'notice', $message, $context ); } /** * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function warning( $message, $context = array() ) { $this->log( 'warning', $message, $context ); } /** * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function error( $message, $context = array() ) { $this->log( 'error', $message, $context ); } /** * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function critical( $message, $context = array() ) { $this->log( 'critical', $message, $context ); } /** * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function alert( $message, $context = array() ) { $this->log( 'alert', $message, $context ); } /** * @param string|\Stringable $message Message. * @param array $context Context. * @return void */ public function emergency( $message, $context = array() ) { $this->log( 'emergency', $message, $context ); } /** * Whether the merchant has opted into plugin debug logging. * * @return bool */ public function is_opt_in_enabled(): bool { $default = (new PluginSettings())->debug_logging_enabled(); return (bool) apply_filters( 'afromewo_log_enabled', $default ); } /** * Resolve WooCommerce logger (cached). * * @return \WC_Logger_Interface|null */ private function get_wc_logger() { if ( $this->wc_logger_resolved ) { return $this->wc_logger; } $this->wc_logger_resolved = true; if ( ! function_exists( 'wc_get_logger' ) ) { $this->wc_logger = null; return null; } $logger = wc_get_logger(); if ( ! $logger instanceof \WC_Logger_Interface ) { $this->wc_logger = null; return null; } $this->wc_logger = $logger; return $this->wc_logger; } /** * Whether WooCommerce will actually persist log lines (WC_Logger is a no-op when this is false). * * @return bool */ private function is_wc_store_logging_enabled(): bool { if ( ! class_exists( \Automattic\WooCommerce\Utilities\LoggingUtil::class ) ) { return true; } try { return \Automattic\WooCommerce\Utilities\LoggingUtil::logging_is_enabled(); } catch ( \Throwable $e ) { return true; } } /** * Send to WC_Logger or fallback file. * * @param string $level WC log level. * @param string $message Interpolated message. * @param array $context Redacted context. * @return void */ private function dispatch( string $level, string $message, array $context ): void { /** * Force plugin file logging even when WC logger is available (e.g. duplicate to file). * When false (default), WC is used only if store logging is enabled. * * @param bool $prefer_fallback Prefer uploads file over WC_Logger. */ $force_fallback = (bool) apply_filters( 'afromewo_log_force_fallback_file', false ); $wc = $this->get_wc_logger(); if ( ! $force_fallback && $wc instanceof \WC_Logger_Interface && $this->is_wc_store_logging_enabled() ) { $wc_context = $context; $wc_context['source'] = (string) apply_filters( 'afromewo_wc_log_source', self::WC_LOG_SOURCE ); $wc_context['afromewo_channel'] = $this->channel; // WC_Logger::log( string $level, string $message, array $context = [] ) $wc->log( $level, $message, $wc_context ); return; } $this->write_line_fallback( $level, $message, $context ); } /** * Map arbitrary level strings to WC-supported levels. * * @param string $level Raw level. * @return string */ private function normalize_wc_level( string $level ): string { $level = strtolower( trim( $level ) ); $valid = array( 'emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug', ); if ( in_array( $level, $valid, true ) ) { return $level; } return 'info'; } /** * Minimum level filter (plugin-specific; WC also applies its own threshold). * * @param string $level Level name. * @return bool */ private function passes_min_level( $level ) { /** * Filter minimum log level for this plugin. * * Supported: debug, info, notice, warning, error, critical, alert, emergency * * @param string $min_level Minimum level. */ $min_level = (string) apply_filters( 'afromewo_log_level', 'debug' ); return $this->level_value( $level ) >= $this->level_value( $min_level ); } /** * Default log file path for fallback writes (uploads directory — plugin dir is often not writable). * * @return string */ private function default_path() { $path = $this->resolve_fallback_log_path(); /** * Filter the absolute path to the fallback log file (when WC logger is unavailable). * * @param string $path Absolute path. */ $filtered = (string) apply_filters( 'afromewo_log_path', $path ); return $this->is_path_in_log_directory( $filtered ) ? $filtered : $path; } /** * Build path: wp-content/uploads/{plugin-slug}/afroit-messaging-workflows.log * * @return string Absolute path, or an empty string if uploads cannot be used. */ private function resolve_fallback_log_path(): string { $dir = $this->get_log_directory( true ); if ( '' === $dir ) { return ''; } return trailingslashit( $dir ) . self::FALLBACK_LOG_FILENAME; } /** * Numeric values for levels (aligned with WC severity ordering). * * @param string $level Level. * @return int */ private function level_value( $level ) { $level = strtolower( trim( (string) $level ) ); $map = array( 'debug' => 100, 'info' => 200, 'notice' => 250, 'warning' => 300, 'error' => 400, 'critical' => 500, 'alert' => 550, 'emergency' => 600, ); return isset( $map[ $level ] ) ? (int) $map[ $level ] : 200; } /** * Add some useful defaults to the context. * * @param array $context Context. * @return array */ private function enrich_context( $context ) { if ( ! isset( $context['request_id'] ) ) { $context['request_id'] = $this->request_id; } if ( ! isset( $context['user_id'] ) && function_exists( 'get_current_user_id' ) ) { $context['user_id'] = (int) get_current_user_id(); } if ( ! isset( $context['url'] ) ) { $context['url'] = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; } if ( ! isset( $context['method'] ) ) { $context['method'] = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; } if ( ! isset( $context['ip'] ) ) { $context['ip'] = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } return $context; } /** * Redact sensitive values by key name. * * @param array $context Context. * @return array */ private function redact_context( $context ) { $redact_if_key_matches = static function ( $key ) { $key = strtolower( (string) $key ); // Redact secrets and common PII-ish fields (including nested arrays). // Keep this conservative: false positives are preferable to leaking customer data into logs. return (bool) preg_match( '/(secret|password|authorization|cookie|nonce|token|api[_-]?key|key|signature|bearer|session' . 'email|e[-_ ]?mail|phone|mobile|tel|msisdn|to|from|' . 'address|street|postcode|zip|city|state|province|country|' . 'customer|user)/i', $key ); }; $walk = static function ( $value ) use ( &$walk, $redact_if_key_matches ) { if ( is_array( $value ) ) { $out = array(); foreach ( $value as $k => $v ) { if ( $redact_if_key_matches( $k ) ) { $out[ $k ] = '[REDACTED]'; } else { $out[ $k ] = $walk( $v ); } } return $out; } return $value; }; return (array) $walk( $context ); } /** * Replace `{key}` tokens in message with context values (PSR-3 style). * * @param string $message Message. * @param array $context Context. * @return string */ private function interpolate( $message, $context ) { $replace = array(); foreach ( (array) $context as $key => $val ) { $replace[ '{' . $key . '}' ] = $this->stringify_value( $val ); } return strtr( (string) $message, $replace ); } /** * Convert message to string. * * @param string|\Stringable $message Message. * @return string */ private function stringify_message( $message ) { if ( is_string( $message ) ) { return $message; } if ( is_object( $message ) && method_exists( $message, '__toString' ) ) { return (string) $message; } return wp_json_encode( $message ); } /** * Convert arbitrary values into a compact string. * * @param mixed $value Value. * @return string */ private function stringify_value( $value ) { if ( null === $value ) { return 'null'; } if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } if ( is_scalar( $value ) ) { return (string) $value; } if ( $value instanceof \Throwable ) { return $value->getMessage(); } return (string) wp_json_encode( $value ); } /** * Fallback line when WooCommerce logger is not available. * * @param string $level Level. * @param string $message Message. * @param array $context Context. * @return void */ private function write_line_fallback( string $level, string $message, array $context ): void { $line = sprintf( '[%s] %s.%s: %s%s', $this->timestamp(), $this->channel, strtoupper( $level ), $message, empty( $context ) ? '' : ' ' . wp_json_encode( $context ) ); $path = $this->path; // Logger may be constructed before uploads/constants are ready; refresh unless path is under uploads/. if ( '' === $path || ! $this->is_absolute_filesystem_path( $path ) || ! $this->is_path_in_log_directory( $path ) ) { $path = $this->default_path(); $this->path = $path; } if ( '' === $path ) { return; } @file_put_contents( $path, $line . PHP_EOL, FILE_APPEND | LOCK_EX ); } /** * Timestamp string. * * @return string */ private function timestamp() { if ( function_exists( 'wp_date' ) ) { return (string) wp_date( 'Y-m-d H:i:s' ); } return (string) gmdate( 'Y-m-d H:i:s' ); } /** * Whether a path is absolute enough for file_put_contents (not a bare filename). * * @param string $path Path. * @return bool */ private function is_absolute_filesystem_path( string $path ): bool { if ( '' === $path ) { return false; } // Same as constant basename only — not a full path. if ( $path === self::FALLBACK_LOG_FILENAME || basename( wp_normalize_path( $path ) ) === wp_normalize_path( $path ) ) { return false; } $n = wp_normalize_path( $path ); if ( str_starts_with( $n, '/' ) ) { return true; } // Windows: C:/... return (bool) preg_match( '#^[a-zA-Z]:/#', $n ); } private function get_log_directory( bool $create ): string { if ( ! function_exists( 'wp_upload_dir' ) ) { return ''; } $upload = wp_upload_dir( null, true ); if ( ! empty( $upload['error'] ) || empty( $upload['basedir'] ) ) { return ''; } $dir = trailingslashit( wp_normalize_path( $upload['basedir'] ) ) . $this->get_plugin_slug(); if ( $create && function_exists( 'wp_mkdir_p' ) ) { wp_mkdir_p( $dir ); } if ( is_dir( $dir ) && wp_is_writable( $dir ) ) { return untrailingslashit( wp_normalize_path( $dir ) ); } return ''; } private function get_plugin_slug(): string { if ( defined( 'AFROMEWO_PLUGIN_SLUG' ) && '' !== (string) AFROMEWO_PLUGIN_SLUG ) { return sanitize_key( (string) AFROMEWO_PLUGIN_SLUG ); } return self::FALLBACK_LOG_SUBDIR; } private function is_path_in_log_directory( string $path ): bool { if ( '' === $path || ! function_exists( 'wp_upload_dir' ) ) { return false; } $log_dir = $this->get_log_directory( true ); if ( '' === $log_dir ) { return false; } $log_dir_real = realpath( $log_dir ); $path_dir_real = realpath( dirname( $path ) ); if ( false === $log_dir_real || false === $path_dir_real ) { return false; } $base = trailingslashit( wp_normalize_path( $log_dir_real ) ); $dir = trailingslashit( wp_normalize_path( $path_dir_real ) ); return str_starts_with( $dir, $base ); } }