PluginProbe
Custom 404 Pro / trunk
Custom 404 Pro vtrunk
3.15.2 3.15.4 3.15.5 3.15.6 3.16.0 3.15.1 3.15.0 3.14.1 3.14.0 3.13.0 trunk 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.1.5 1.1.6 1.2.0 1.3.10 1.3.12 1.3.5 All 100 releases
custom-404-pro / admin / class-helpers.php

class-helpers.php in Custom 404 Pro trunk, at admin/class-helpers.php

398 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Helper utilities for the plugin.
4 *
5 * @package Custom_404_Pro
6 */
7
8 /**
9 * Helpers class.
10 */
11 class Helpers {
12
13 /**
14 * Singleton instance.
15 *
16 * @var Helpers
17 */
18 private static $instance;
19
20 /**
21 * Logs table name (without prefix).
22 *
23 * @var string
24 */
25 public $table_logs;
26
27 /**
28 * wp_options key used to store all plugin settings.
29 *
30 * @var string
31 */
32 const OPTION_KEY = 'custom_404_pro_settings';
33
34 /**
35 * Returns the singleton instance of this class.
36 *
37 * @return Helpers
38 */
39 public static function singleton() {
40 static $inst = null;
41 if ( null === $inst ) {
42 $inst = new Helpers();
43 }
44 return $inst;
45 }
46
47 /**
48 * Constructor.
49 */
50 public function __construct() {
51 $this->table_logs = 'custom_404_pro_logs';
52 }
53
54 /**
55 * Returns the default values for all plugin settings.
56 *
57 * @since 3.12.9
58 * @return array
59 */
60 public function defaults(): array {
61 return array(
62 'mode' => '',
63 'mode_page' => '',
64 'mode_url' => '',
65 'send_email' => false,
66 'logging_enabled' => false,
67 'redirect_error_code' => 302,
68 'log_ip' => true,
69 'email_cooldown' => 3600,
70 'log_retention_count' => 0,
71 'log_retention_days' => 0,
72 );
73 }
74
75 /**
76 * Returns all plugin settings, falling back to defaults for any missing keys.
77 *
78 * @since 3.12.9
79 * @return array
80 */
81 public function get_settings(): array {
82 $saved = get_option( self::OPTION_KEY );
83 if ( ! is_array( $saved ) ) {
84 return $this->defaults();
85 }
86 return array_merge( $this->defaults(), $saved );
87 }
88
89 /**
90 * Returns a single setting value by key.
91 *
92 * @since 3.12.9
93 * @param string $key Setting key.
94 * @return mixed Setting value, or the default for that key if not set.
95 */
96 public function get_setting( string $key ) {
97 $settings = $this->get_settings();
98 return $settings[ $key ] ?? $this->defaults()[ $key ] ?? null;
99 }
100
101 /**
102 * Merges the supplied values into the current settings and persists them.
103 *
104 * Only the keys present in $new_settings are updated; all other settings
105 * retain their current values.
106 *
107 * @since 3.12.9
108 * @param array $new_settings Key/value pairs to update.
109 * @return bool True on success, false on failure.
110 */
111 public function update_settings( array $new_settings ): bool {
112 $merged = array_merge( $this->get_settings(), $new_settings );
113 return (bool) update_option( self::OPTION_KEY, $merged );
114 }
115
116 /**
117 * Generates an admin notice HTML string.
118 *
119 * @param string $type Notice type (success, error, warning, info).
120 * @param string $message Notice message.
121 * @return string HTML for the notice.
122 */
123 public function admin_notice( $type, $message ) {
124 $html = '';
125 $html .= '<div class="notice notice-' . $type . '">';
126 $html .= ' <p>' . $message . '</p>';
127 $html .= '</div>';
128 return $html;
129 }
130
131 /**
132 * Returns the column definitions for the logs table.
133 *
134 * @return array|null Array of column objects, or null.
135 */
136 public function get_logs_columns() {
137 global $wpdb;
138 $query = 'SHOW COLUMNS FROM ' . $wpdb->prefix . $this->table_logs; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
139 $result = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
140 return $result;
141 }
142
143 /**
144 * Returns the count of legacy log posts.
145 *
146 * @return int Number of old log posts.
147 */
148 public function get_old_logs_count() {
149 global $wpdb;
150 $query = $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_type = %s", 'c4p_log' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
151 $result = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
152 return (int) $result;
153 }
154
155 /**
156 * Deletes legacy log posts by ID.
157 *
158 * @param array $log_ids Array of post IDs to delete.
159 */
160 public function delete_old_logs( $log_ids ) {
161 foreach ( $log_ids as $id ) {
162 wp_delete_post( $id, true );
163 }
164 }
165
166 /**
167 * Creates log entries in the logs table from legacy post data.
168 *
169 * @param array $logs_data Array of log data objects.
170 * @param bool $is_deleting_old Whether to delete legacy posts after migration.
171 * @return int|false Number of rows inserted, or false on error.
172 */
173 public function create_logs( $logs_data, $is_deleting_old ) {
174 global $wpdb;
175 $log_ids = array();
176 $result = false;
177 foreach ( $logs_data as $log ) {
178 if ( ! empty( $log->id ) ) {
179 array_push( $log_ids, $log->id );
180 }
181 $result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
182 $wpdb->prefix . $this->table_logs,
183 array(
184 'ip' => $log->ip,
185 'path' => $log->path,
186 'referer' => $log->referer,
187 'user_agent' => $log->user_agent,
188 )
189 );
190 }
191 if ( ! is_wp_error( $result ) ) {
192 if ( ! empty( $is_deleting_old ) && $is_deleting_old ) {
193 self::delete_old_logs( $log_ids );
194 }
195 }
196 return $result;
197 }
198
199 /**
200 * Retrieves all log entries from the logs table.
201 *
202 * @return array|null Array of log rows, or null.
203 */
204 public function get_logs() {
205 global $wpdb;
206 $query = 'SELECT * FROM ' . $wpdb->prefix . $this->table_logs; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
207 $result = $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
208 return $result;
209 }
210
211 /**
212 * Deletes log entries from the logs table.
213 *
214 * @param string|int|array $path 'all' to truncate, array of IDs for bulk delete, or single ID.
215 * @return int|false Number of rows affected, or false on error.
216 */
217 public function delete_logs( $path ) {
218 global $wpdb;
219 if ( 'all' === $path ) {
220 $query = 'TRUNCATE TABLE ' . $wpdb->prefix . $this->table_logs; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
221 $result = $wpdb->query( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
222 } elseif ( is_array( $path ) ) {
223 $ids = array_map( 'absint', $path );
224 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
225 $query = $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . $this->table_logs . ' WHERE id IN (' . $placeholders . ')', ...$ids ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.PreparedSQLPlaceholders, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
226 $result = $wpdb->query( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
227 } else {
228 $query = $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . $this->table_logs . ' WHERE id = %d', $path ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
229 $result = $wpdb->query( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
230 }
231 return $result;
232 }
233
234 /**
235 * Returns the total number of rows in the logs table.
236 *
237 * @since 3.14.0
238 * @return int Total log row count.
239 */
240 public function get_logs_count(): int {
241 global $wpdb;
242 $result = $wpdb->get_var( 'SELECT COUNT(*) FROM ' . $wpdb->prefix . $this->table_logs ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
243 return (int) $result;
244 }
245
246 /**
247 * Prunes log entries according to the configured retention settings.
248 *
249 * Two independent passes are run:
250 * 1. Count pass: if log_retention_count > 0 and the table exceeds that cap,
251 * the oldest rows (by `created` timestamp) are deleted until only
252 * log_retention_count rows remain.
253 * 2. Age pass: if log_retention_days > 0, all rows older than that many days
254 * are deleted.
255 *
256 * Returns the total number of rows deleted across both passes.
257 *
258 * @since 3.14.0
259 * @return int Total rows deleted.
260 */
261 public function prune_logs(): int {
262 global $wpdb;
263 $options = $this->get_settings();
264 $deleted = 0;
265 $table = $wpdb->prefix . $this->table_logs;
266
267 $max_count = isset( $options['log_retention_count'] ) ? (int) $options['log_retention_count'] : 0;
268 if ( $max_count > 0 ) {
269 $total = $this->get_logs_count();
270 $excess = $total - $max_count;
271 if ( $excess > 0 ) {
272 $query = $wpdb->prepare( 'DELETE FROM ' . $table . ' ORDER BY created ASC LIMIT %d', $excess ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
273 $deleted += (int) $wpdb->query( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
274 }
275 }
276
277 $max_days = isset( $options['log_retention_days'] ) ? (int) $options['log_retention_days'] : 0;
278 if ( $max_days > 0 ) {
279 $query = $wpdb->prepare( 'DELETE FROM ' . $table . ' WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY)', $max_days ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
280 $deleted += (int) $wpdb->query( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
281 }
282
283 return $deleted;
284 }
285
286 /**
287 * Number of log rows read per batch when streaming a CSV export.
288 *
289 * @since 3.15.5
290 * @var int
291 */
292 const EXPORT_BATCH_SIZE = 1000;
293
294 /**
295 * Neutralises a value that a spreadsheet would interpret as a formula.
296 *
297 * Log rows contain attacker-supplied data: an request to a 404 URL can set
298 * any Referer or User-Agent it likes. A field beginning with =, +, -, @ or a
299 * control character is executed as a formula when the exported CSV is opened
300 * in Excel, LibreOffice or Google Sheets (CSV injection, CWE-1236). Prefixing
301 * the value with an apostrophe forces the spreadsheet to treat it as text.
302 *
303 * @since 3.15.5
304 * @param mixed $value Raw column value.
305 * @return string Value that is safe to write to a CSV cell.
306 */
307 public function escape_csv_value( $value ): string {
308 $value = (string) $value;
309
310 if ( '' === $value ) {
311 return $value;
312 }
313
314 if ( in_array( $value[0], array( '=', '+', '-', '@', "\t", "\r" ), true ) ) {
315 $value = "'" . $value;
316 }
317
318 return $value;
319 }
320
321 /**
322 * Writes one row to an open CSV stream.
323 *
324 * $escape is passed explicitly for two reasons. PHP 8.4 deprecates calling
325 * fputcsv() without it — on a site with WP_DEBUG display enabled those
326 * notices would be emitted straight into the download and corrupt the file.
327 * And the historical default, a backslash escape, is a PHP quirk that no
328 * spreadsheet expects; passing an empty string disables it and produces
329 * plain RFC 4180 output, where a quote is escaped by doubling it.
330 *
331 * The empty-string escape has been accepted since PHP 7.4, which is the
332 * minimum this plugin declares.
333 *
334 * @since 3.15.5
335 * @param resource $handle Open stream to write to.
336 * @param array $row Values for one CSV record.
337 * @return void
338 */
339 public function write_csv_row( $handle, array $row ) {
340 fputcsv( $handle, $row, ',', '"', '' );
341 }
342
343 /**
344 * Exports all log entries as a CSV file download.
345 *
346 * Rows are streamed to the browser in batches rather than concatenated into
347 * a single string, so exporting a large log table does not exhaust PHP's
348 * memory limit. Values are written with fputcsv() so that embedded quotes,
349 * commas and newlines are quoted correctly, and each value is passed through
350 * escape_csv_value() first to defuse spreadsheet formula injection.
351 *
352 * @since 3.15.5 Streams in batches; values are CSV-quoted and formula-escaped.
353 */
354 public function export_logs_csv() {
355 global $wpdb;
356
357 $filename = 'custom-404-pro-logs-' . gmdate( 'Y-m-d-His' ) . '.csv';
358
359 nocache_headers();
360 header( 'Content-Type: text/csv; charset=utf-8' );
361 header( 'Content-Disposition: attachment; filename=' . $filename );
362
363 $handle = fopen( 'php://output', 'w' );
364 if ( false === $handle ) {
365 return;
366 }
367
368 $columns = $this->get_logs_columns();
369 $fields = array();
370 foreach ( (array) $columns as $column ) {
371 $fields[] = $column->Field; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Field is a wpdb column object property
372 }
373 if ( ! empty( $fields ) ) {
374 $this->write_csv_row( $handle, $fields );
375 }
376
377 $table = $wpdb->prefix . $this->table_logs;
378 $offset = 0;
379 $row_count = 0;
380 do {
381 $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
382 $wpdb->prepare( 'SELECT * FROM ' . $table . ' ORDER BY id ASC LIMIT %d OFFSET %d', self::EXPORT_BATCH_SIZE, $offset ),
383 ARRAY_A
384 );
385 $row_count = count( $rows );
386
387 foreach ( $rows as $row ) {
388 $this->write_csv_row( $handle, array_map( array( $this, 'escape_csv_value' ), $row ) );
389 }
390
391 $offset += self::EXPORT_BATCH_SIZE;
392 } while ( self::EXPORT_BATCH_SIZE === $row_count );
393
394 fclose( $handle );
395 exit;
396 }
397 }
398