PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.1
5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
double-opt-in / src / Audit / AuditLogger.php

AuditLogger.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.6.1, at src/Audit/AuditLogger.php

273 lines 6.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Audit Logger
4 *
5 * @package Forge12\DoubleOptIn\Audit
6 * @since 4.2.0
7 */
8
9 namespace Forge12\DoubleOptIn\Audit;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class AuditLogger
17 *
18 * Logs audit events to a dedicated database table.
19 */
20 class AuditLogger {
21
22 /**
23 * Table name (without prefix).
24 */
25 const TABLE_NAME = 'f12_cf7_doubleoptin_audit_log';
26
27 /**
28 * Event types.
29 */
30 const TYPE_SETTINGS = 'settings';
31 const TYPE_CRON = 'cron';
32 const TYPE_ACTIVATION = 'activation';
33 const TYPE_RATE_LIMIT = 'rate_limit';
34 const TYPE_API_ERROR = 'api_error';
35 const TYPE_DB_ERROR = 'db_error';
36 const TYPE_EMAIL = 'email';
37 const TYPE_AUTH = 'auth';
38 const TYPE_FOLLOW_UP = 'follow_up';
39
40 /**
41 * Severity levels.
42 */
43 const SEVERITY_INFO = 'info';
44 const SEVERITY_WARNING = 'warning';
45 const SEVERITY_ERROR = 'error';
46 const SEVERITY_CRITICAL = 'critical';
47
48 /**
49 * Log an audit event.
50 *
51 * @param string $type Event type (see TYPE_* constants).
52 * @param string $severity Severity level (see SEVERITY_* constants).
53 * @param string $message Human-readable event description.
54 * @param array $details Optional additional details.
55 *
56 * @return int|false The inserted row ID or false on failure.
57 */
58 public static function log( string $type, string $severity, string $message, array $details = array() ) {
59 global $wpdb;
60
61 $table = $wpdb->prefix . self::TABLE_NAME;
62
63 // Validate severity
64 $validSeverities = array( self::SEVERITY_INFO, self::SEVERITY_WARNING, self::SEVERITY_ERROR, self::SEVERITY_CRITICAL );
65 if ( ! in_array( $severity, $validSeverities, true ) ) {
66 $severity = self::SEVERITY_INFO;
67 }
68
69 $result = $wpdb->insert(
70 $table,
71 array(
72 'event_type' => sanitize_text_field( $type ),
73 'severity' => $severity,
74 'message' => sanitize_text_field( $message ),
75 'user_id' => get_current_user_id() ?: null,
76 'details' => ! empty( $details ) ? wp_json_encode( $details ) : null,
77 'created_at' => current_time( 'mysql', true ),
78 ),
79 array( '%s', '%s', '%s', '%d', '%s', '%s' )
80 );
81
82 return $result ? $wpdb->insert_id : false;
83 }
84
85 /**
86 * Get audit events with filtering and pagination.
87 *
88 * @param array $args Query arguments.
89 *
90 * @return array { events: array, total: int, pages: int }
91 */
92 public static function getEvents( array $args = array() ): array {
93 global $wpdb;
94
95 $defaults = array(
96 'period' => 30,
97 'type' => '',
98 'severity' => '',
99 'page' => 1,
100 'per_page' => 15,
101 );
102
103 $args = wp_parse_args( $args, $defaults );
104
105 $table = $wpdb->prefix . self::TABLE_NAME;
106 $where = array( '1=1' );
107 $params = array();
108
109 // Period filter
110 if ( $args['period'] > 0 ) {
111 $where[] = 'created_at >= %s';
112 $params[] = gmdate( 'Y-m-d H:i:s', strtotime( "-{$args['period']} days" ) );
113 }
114
115 // Type filter. Empty AND the literal "all" sentinel both mean
116 // "no filter" — the React SPA's <Select> sends "all" as the
117 // default-selected value, and pre-fix that was matched as
118 // `event_type = 'all'` in the WHERE, returning zero rows even
119 // when the dropdown was clearly at "All" (user-reported bug
120 // 2026-04-30: "Audit log shows totals but no events listed").
121 if ( ! empty( $args['type'] ) && $args['type'] !== 'all' ) {
122 $where[] = 'event_type = %s';
123 $params[] = sanitize_text_field( $args['type'] );
124 }
125
126 // Severity filter — same sentinel handling as type.
127 if ( ! empty( $args['severity'] ) && $args['severity'] !== 'all' ) {
128 $where[] = 'severity = %s';
129 $params[] = sanitize_text_field( $args['severity'] );
130 }
131
132 $whereClause = implode( ' AND ', $where );
133
134 // Count total
135 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
136 if ( ! empty( $params ) ) {
137 $countQuery = $wpdb->prepare( $countQuery, $params );
138 }
139 $total = (int) $wpdb->get_var( $countQuery );
140
141 // Get events
142 $perPage = max( 1, (int) $args['per_page'] );
143 $page = max( 1, (int) $args['page'] );
144 $offset = ( $page - 1 ) * $perPage;
145
146 $query = "SELECT * FROM {$table} WHERE {$whereClause} ORDER BY created_at DESC LIMIT %d OFFSET %d";
147 $params[] = $perPage;
148 $params[] = $offset;
149
150 $events = $wpdb->get_results( $wpdb->prepare( $query, $params ), ARRAY_A );
151
152 // Parse details JSON
153 foreach ( $events as &$event ) {
154 $event['details'] = ! empty( $event['details'] ) ? json_decode( $event['details'], true ) : null;
155 if ( $event['user_id'] ) {
156 $user = get_userdata( (int) $event['user_id'] );
157 $event['user_display'] = $user ? $user->display_name : __( 'Unknown', 'double-opt-in' );
158 } else {
159 $event['user_display'] = __( 'System', 'double-opt-in' );
160 }
161 }
162
163 return array(
164 'events' => $events ?: array(),
165 'total' => $total,
166 'pages' => (int) ceil( $total / $perPage ),
167 );
168 }
169
170 /**
171 * Get summary counts by severity for a given period.
172 *
173 * @param int $period Days to look back.
174 *
175 * @return array { total, info, warning, error, critical }
176 */
177 public static function getSummary( int $period = 30 ): array {
178 global $wpdb;
179
180 $table = $wpdb->prefix . self::TABLE_NAME;
181 $dateFrom = gmdate( 'Y-m-d H:i:s', strtotime( "-{$period} days" ) );
182
183 $results = $wpdb->get_results(
184 $wpdb->prepare(
185 "SELECT severity, COUNT(*) as count FROM {$table} WHERE created_at >= %s GROUP BY severity",
186 $dateFrom
187 ),
188 ARRAY_A
189 );
190
191 $summary = array(
192 'total' => 0,
193 'info' => 0,
194 'warning' => 0,
195 'error' => 0,
196 'critical' => 0,
197 );
198
199 foreach ( $results as $row ) {
200 $sev = $row['severity'];
201 $cnt = (int) $row['count'];
202 if ( isset( $summary[ $sev ] ) ) {
203 $summary[ $sev ] = $cnt;
204 }
205 $summary['total'] += $cnt;
206 }
207
208 return $summary;
209 }
210
211 /**
212 * Register WordPress hooks for automatic audit logging.
213 *
214 * @return void
215 */
216 public static function registerHooks(): void {
217 // Log settings changes
218 add_action(
219 'update_option_f12-doi-settings',
220 function ( $old, $new ) {
221 self::log( self::TYPE_SETTINGS, self::SEVERITY_INFO, __( 'Global settings updated.', 'double-opt-in' ) );
222 },
223 10,
224 2
225 );
226
227 // Log form settings changes
228 add_action(
229 'f12_doi_form_settings_saved',
230 function ( $formId ) {
231 self::log(
232 self::TYPE_SETTINGS,
233 self::SEVERITY_INFO,
234 sprintf(
235 __( 'Form settings saved for form %s.', 'double-opt-in' ),
236 $formId
237 )
238 );
239 },
240 10,
241 1
242 );
243
244 // Log cron runs
245 add_action(
246 'f12_doi_cron_cleanup_done',
247 function ( $counts ) {
248 if ( is_array( $counts ) && array_sum( $counts ) > 0 ) {
249 self::log( self::TYPE_CRON, self::SEVERITY_INFO, __( 'Scheduled cleanup completed.', 'double-opt-in' ), $counts );
250 }
251 }
252 );
253
254 // Log rate limit hits
255 add_action(
256 'f12_doi_rate_limit_hit',
257 function ( $type, $identifier ) {
258 self::log(
259 self::TYPE_RATE_LIMIT,
260 self::SEVERITY_WARNING,
261 sprintf(
262 __( 'Rate limit reached for %1$s: %2$s', 'double-opt-in' ),
263 $type,
264 $identifier
265 )
266 );
267 },
268 10,
269 2
270 );
271 }
272 }
273