PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.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 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / Audit / AuditLogger.php

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

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