logCreate($id, $data); * DBLog::for('campaign')->logUpdate($id, $before, $after); * DBLog::for('campaign')->logSoftDelete($id); * DBLog::for('campaign')->logHardDelete($id); * DBLog::for('campaign')->logRestore($id); * DBLog::for('campaign')->logFailure('create', $id); * DBLog::for('campaign')->logBulk('soft_delete', ['succeeded' => [], 'failed' => []]); * * Reading logs (admin filter screen): * DBLog::getLogs(['entity_type' => 'campaign', 'log_level' => 'warning'], 50, 1); * DBLog::getLogMeta($auditLogId); */ class DBLog { // ----------------------------------------------------------------- // Sensitive keys that are redacted in update diffs // ----------------------------------------------------------------- private const REDACTED_KEYS = array( 'password', 'token', 'secret', 'api_key', 'auth_key', ); // ----------------------------------------------------------------- // Instance state // ----------------------------------------------------------------- private string $entityType = ''; private int $userId = 0; private string $ipAddress = ''; private string $userAgent = ''; // ----------------------------------------------------------------- // Constructor / fluent factory // ----------------------------------------------------------------- private function __construct() { } /** * Create a logger bound to a specific entity type. * * @param string $entityType e.g. 'campaign', 'ad_space' * * @return static */ public static function for( string $entityType ): static { $instance = new static(); $instance->entityType = $entityType; $instance->userId = get_current_user_id(); $instance->ipAddress = $instance->resolveIp(); $instance->userAgent = substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ), 0, 500 ); return $instance; } // ----------------------------------------------------------------- // Public logging methods // ----------------------------------------------------------------- /** * Log a successful record creation. */ public function logCreate( int $entityId, array $data = array() ): int { return $this->writeLog( action: 'create', entityId: $entityId, level: 'info', details: array( 'created' => $this->redact( $data ) ), ); } /** * Log a successful record update. * Automatically diffs $before and $after — only changed fields are stored. */ public function logUpdate( int $entityId, array $before, array $after ): int { $diff = $this->diff( $before, $after ); if ( empty( $diff ) ) { return 0; // nothing actually changed } return $this->writeLog( action: 'update', entityId: $entityId, level: 'info', details: $diff, ); } /** * Log a soft delete. */ public function logSoftDelete( int $entityId, array $details = array() ): int { return $this->writeLog( action: 'soft_delete', entityId: $entityId, level: 'warning', details: $details, ); } /** * Log a hard (permanent) delete. */ public function logHardDelete( int $entityId, array $details = array() ): int { return $this->writeLog( action: 'hard_delete', entityId: $entityId, level: 'warning', details: $details, ); } /** * Log a record restore. */ public function logRestore( int $entityId ): int { return $this->writeLog( action: 'restore', entityId: $entityId, level: 'info', details: array(), ); } /** * Log a failed operation (any type). * * @param string $operation e.g. 'create', 'update', 'soft_delete' * @param int $entityId 0 if the record was never created */ public function logFailure( string $operation, int $entityId = 0, array $context = array() ): int { global $wpdb; return $this->writeLog( action: 'failure_' . $operation, entityId: $entityId, level: 'error', details: array( 'operation' => $operation, 'db_error' => $wpdb->last_error ?: null, 'context' => $this->redact( $context ), ), ); } /** * Log a bulk operation as a single summary row. * * @param string $operation e.g. 'soft_delete', 'hard_delete' * @param array $results ['succeeded' => [1,2,3], 'failed' => [4]] */ public function logBulk( string $operation, array $results ): int { $succeeded = $results['succeeded'] ?? array(); $failed = $results['failed'] ?? array(); $level = empty( $failed ) ? 'info' : 'warning'; // Use the first succeeded ID as the entity ID for the log row, // or 0 if all failed. $entityId = (int) ( $succeeded[0] ?? 0 ); return $this->writeLog( action: 'bulk_' . $operation, entityId: $entityId, level: $level, details: array( 'succeeded_ids' => $succeeded, 'failed_ids' => $failed, 'succeeded_count' => count( $succeeded ), 'failed_count' => count( $failed ), ), ); } /** * Store extra key/value pairs in adfuz_audit_logs_meta. * * @param int $auditLogId The ID returned by any log* method above. * @param array $meta ['key' => 'value', ...] */ public function writeMeta( int $auditLogId, array $meta ): void { if ( $auditLogId <= 0 || empty( $meta ) ) { return; } $now = current_time( 'mysql' ); foreach ( $meta as $key => $value ) { DB::get( TableName::ADFUZ_AUDIT_LOGS_META() )->insert( array( 'audit_log_id' => $auditLogId, 'meta_key' => (string) $key, 'meta_value' => is_array( $value ) ? wp_json_encode( $value ) : (string) $value, 'created_at' => $now, 'updated_at' => $now, ) ); } } // ----------------------------------------------------------------- // Static query helpers (for the admin filter / list screen) // ----------------------------------------------------------------- /** * Paginated log query with optional filters. * * Supported filters: * entity_type string * entity_id int * log_action string e.g. 'create', 'update', 'bulk_soft_delete' * log_level string 'info' | 'warning' | 'error' | 'critical' * user_id int * date_from string 'Y-m-d' or 'Y-m-d H:i:s' * date_to string 'Y-m-d' or 'Y-m-d H:i:s' * * @return array{ data: array, total: int, per_page: int, current_page: int, last_page: int } */ public static function getLogs( array $filters = array(), int $perPage = 50, int $page = 1 ): array { $query = DB::get( TableName::ADFUZ_AUDIT_LOGS() ) ->orderBy( 'id', 'DESC' ); if ( ! empty( $filters['entity_type'] ) ) { $query->where( 'entity_type', $filters['entity_type'] ); } if ( ! empty( $filters['entity_id'] ) ) { $query->where( 'entity_id', (int) $filters['entity_id'] ); } if ( ! empty( $filters['log_action'] ) ) { $query->where( 'log_action', $filters['log_action'] ); } if ( ! empty( $filters['log_level'] ) ) { $query->where( 'log_level', $filters['log_level'] ); } if ( ! empty( $filters['user_id'] ) ) { $query->where( 'user_id', (int) $filters['user_id'] ); } if ( ! empty( $filters['date_from'] ) ) { $query->where( 'created_at', '>=', $filters['date_from'] ); } if ( ! empty( $filters['date_to'] ) ) { $query->where( 'created_at', '<=', $filters['date_to'] ); } return $query->paginate( $perPage, $page ); } /** * Fetch all meta rows for a single audit log entry. * * @return array Array of meta rows (associative arrays) */ public static function getLogMeta( int $auditLogId ): array { return DB::get( TableName::ADFUZ_AUDIT_LOGS_META() ) ->where( 'audit_log_id', $auditLogId ) ->orderBy( 'meta_id', 'ASC' ) ->get(); } // ----------------------------------------------------------------- // Core write method // ----------------------------------------------------------------- /** * Insert a single row into adfuz_audit_logs. * * @return int The new log row ID, or 0 on failure. */ private function writeLog( string $action, int $entityId, string $level = 'info', array $details = array(), ): int { // --- Audit log gate --- $settings = get_option( SettingsController::OPTION_KEY, array() ); if ( empty( $settings['enable_audit_log'] ) ) { return 0; } // --- end gate --- try { $logId = DB::get( TableName::ADFUZ_AUDIT_LOGS() )->insert( array( 'user_id' => $this->userId ?: null, 'log_action' => $action, 'entity_type' => $this->entityType, 'entity_id' => $entityId, 'change_details' => ! empty( $details ) ? wp_json_encode( $details ) : null, 'ip_address' => $this->ipAddress, 'user_agent' => $this->userAgent ?: null, 'log_level' => $level, 'created_at' => current_time( 'mysql' ), ) ); return (int) $logId; } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[DBLog] writeLog failed: ' . $e->getMessage() ); } return 0; } } // ----------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------- /** * Diff two associative arrays. * Returns ['field' => ['before' => x, 'after' => y], ...] * for every key whose value changed. */ private function diff( array $before, array $after ): array { $diff = array(); foreach ( $after as $key => $newValue ) { $oldValue = $before[ $key ] ?? null; // Cast both to string for a safe loose comparison. if ( (string) $oldValue !== (string) $newValue ) { if ( in_array( $key, self::REDACTED_KEYS, true ) ) { $diff[ $key ] = array( 'before' => '***', 'after' => '***', ); } else { $diff[ $key ] = array( 'before' => $oldValue, 'after' => $newValue, ); } } } return $diff; } /** * Redact sensitive keys from an array before storing it. */ private function redact( array $data ): array { foreach ( self::REDACTED_KEYS as $key ) { if ( array_key_exists( $key, $data ) ) { $data[ $key ] = '***'; } } return $data; } /** * Resolve the real client IP, respecting proxies and Cloudflare. */ private function resolveIp(): string { $candidates = array( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '' ) ), sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ?? '' ) ), sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '' ) ), sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ), ); foreach ( $candidates as $candidate ) { $ip = trim( explode( ',', $candidate )[0] ); if ( $ip && filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $ip; } } return ''; } }