PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / trunk
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification vtrunk
5.8.0 5.8.1 5.7.0 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 All 41 releases
double-opt-in / src / Audit / AuditLogger.php

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

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