*/ class Log_Auditor_Admin { /** * The ID of this plugin. * * @since 1.0.0 * @access private * @var string $plugin_name The ID of this plugin. */ private $plugin_name; /** * The version of this plugin. * * @since 1.0.0 * @access private * @var string $version The current version of this plugin. */ private $version; /** * Hook suffixes of the plugin's admin screens. * * @since 1.0.0 * @access private * @var string[] $page_hooks */ private $page_hooks = array(); /** * Tracking toggles shown on the Settings screen. * * @since 1.0.0 * @return array option => label. */ public static function tracking_options() { return array( 'log_auditor_track_login' => __( 'Login / logout', 'entangle-log-auditor' ), 'log_auditor_track_login_failed' => __( 'Failed logins', 'entangle-log-auditor' ), 'log_auditor_track_content' => __( 'Content activity', 'entangle-log-auditor' ), 'log_auditor_track_users' => __( 'User & role activity', 'entangle-log-auditor' ), 'log_auditor_track_plugins_themes' => __( 'Plugins & themes', 'entangle-log-auditor' ), 'log_auditor_track_updates' => __( 'Updates', 'entangle-log-auditor' ), 'log_auditor_track_options' => __( 'Selected WordPress settings', 'entangle-log-auditor' ), ); } /** * Initialize the class and set its properties. * * @since 1.0.0 * @param string $plugin_name The name of this plugin. * @param string $version The version of this plugin. */ public function __construct( $plugin_name, $version ) { $this->plugin_name = $plugin_name; $this->version = $version; } /** * Register the admin menu and submenus. * * @since 1.0.0 */ public function add_menu() { $this->page_hooks[] = add_menu_page( __( 'EC Intelligent Log Auditor', 'entangle-log-auditor' ), __( 'EC Intelligent Log Auditor', 'entangle-log-auditor' ), 'manage_options', 'log-auditor', array( $this, 'render_logs_page' ), 'dashicons-visibility', 70 ); $this->page_hooks[] = add_submenu_page( 'log-auditor', __( 'Activity Log', 'entangle-log-auditor' ), __( 'Activity Log', 'entangle-log-auditor' ), 'manage_options', 'log-auditor', array( $this, 'render_logs_page' ) ); $this->page_hooks[] = add_submenu_page( 'log-auditor', __( 'Settings', 'entangle-log-auditor' ), __( 'Settings', 'entangle-log-auditor' ), 'manage_options', 'log-auditor-settings', array( $this, 'render_settings_page' ) ); $this->page_hooks[] = add_submenu_page( 'log-auditor', __( 'Help Center', 'entangle-log-auditor' ), __( 'Help Center', 'entangle-log-auditor' ), 'manage_options', 'log-auditor-help', array( $this, 'render_help_page' ) ); } /** * Suppress third-party admin notices on the plugin's own screens. * * Runs on in_admin_header, just before WordPress prints the notice hooks. * The plugin's own messages are rendered inline by its partials. * * @since 1.0.0 */ public function hide_admin_notices() { $screen = get_current_screen(); if ( ! $screen || ! in_array( $screen->id, $this->page_hooks, true ) ) { return; } remove_all_actions( 'admin_notices' ); remove_all_actions( 'all_admin_notices' ); remove_all_actions( 'network_admin_notices' ); remove_all_actions( 'user_admin_notices' ); } /** * Register the stylesheets for the plugin's admin screens. * * @since 1.0.0 * @param string $hook_suffix Current admin page. */ public function enqueue_styles( $hook_suffix ) { if ( ! in_array( $hook_suffix, $this->page_hooks, true ) ) { return; } wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/log-auditor-admin.css', array( 'dashicons' ), $this->version, 'all' ); wp_enqueue_style( $this->plugin_name . '-detail-modal', plugin_dir_url( __FILE__ ) . 'css/log-auditor-detail-modal.css', array( $this->plugin_name ), $this->version, 'all' ); } /** * Register the JavaScript for the plugin's admin screens. * * @since 1.0.0 * @param string $hook_suffix Current admin page. */ public function enqueue_scripts( $hook_suffix ) { if ( ! in_array( $hook_suffix, $this->page_hooks, true ) ) { return; } wp_enqueue_script( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'js/log-auditor-admin.js', array( 'jquery' ), $this->version, true ); wp_localize_script( $this->plugin_name, 'logAuditorAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'log_auditor_log_details' ), 'i18n' => array( 'title' => __( 'Log Details', 'entangle-log-auditor' ), 'close' => __( 'Close log details', 'entangle-log-auditor' ), 'loading' => __( 'Loading activity details…', 'entangle-log-auditor' ), 'error' => __( 'Unable to load log details. Please refresh and try again.', 'entangle-log-auditor' ), 'match' => __( '1 matching question', 'entangle-log-auditor' ), /* translators: %d: number of matching questions (not 1). */ 'matches' => __( '%d matching questions', 'entangle-log-auditor' ), ), ) ); } /** * "Content Updated" from content_updated. * * @since 1.0.0 * @param string $event Event slug. * @return string */ public static function human_event_label( $event ) { return ucwords( str_replace( '_', ' ', sanitize_key( (string) $event ) ) ); } /** * Colour group for an event badge. * * @since 1.0.0 * @param string $event Event slug. * @return string danger|access|people|extend|update|content|neutral. */ public static function event_tone( $event ) { $event = sanitize_key( (string) $event ); $tones = array( 'login_failed' => 'danger', 'login' => 'access', 'logout' => 'access', 'user_' => 'people', 'role_' => 'people', 'plugin_' => 'extend', 'theme_' => 'extend', 'update_' => 'update', 'content_' => 'content', ); foreach ( $tones as $prefix => $tone ) { if ( 0 === strpos( $event, $prefix ) ) { return $tone; } } return 'neutral'; } /** * "Page#12 — About" style object label. * * @since 1.0.0 * @param array $row Log row. * @return string */ public static function format_object_display( $row ) { $type = sanitize_key( (string) ( $row['object_type'] ?? '' ) ); $type = $type ? ucfirst( $type ) : '—'; $id = ! empty( $row['object_id'] ) ? '#' . absint( $row['object_id'] ) : ''; $title = (string) ( $row['object_title'] ?? '' ); return trim( $type . $id . ( '' !== $title ? ' — ' . $title : '' ) ); } /** * "City, Region, CC" or empty string. * * @since 1.0.0 * @param array $row Log row. * @return string */ public static function format_location( $row ) { return implode( ', ', array_filter( array( $row['city'] ?? '', $row['region'] ?? '', $row['country'] ?? '' ) ) ); } /** * Shared header with brand and tab navigation. * * @since 1.0.0 * @param string $active activity|settings|help. */ public function render_app_header( $active ) { $active = sanitize_key( $active ); include plugin_dir_path( __FILE__ ) . 'partials/log-auditor-admin-header.php'; } /** * Dashboard counters for the Activity Log screen. * * @return array events (30 days), users (30 days), failed (24 hours). */ private function stats() { global $wpdb; $table = Log_Auditor_DB::table_name(); $month = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ); $day = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ); // One pass over the 30-day window; the (time_gmt, event_type, user_id) index covers every column used. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live counters from custom audit table. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS events, COUNT(DISTINCT CASE WHEN user_id > 0 THEN user_id END) AS users, SUM(time_gmt >= %s AND event_type = %s) AS failed FROM %i WHERE time_gmt >= %s', $day, 'login_failed', $table, $month ), ARRAY_A ); return array( 'events' => (int) ( $row['events'] ?? 0 ), 'users' => (int) ( $row['users'] ?? 0 ), 'failed' => (int) ( $row['failed'] ?? 0 ), ); } /** * Latest 200 rows, optionally filtered by event, username, object title or IP. * * @param string $search Search term. * @return array */ private function recent_logs( $search ) { global $wpdb; $table = Log_Auditor_DB::table_name(); // Only the columns the table shows; before/after values and user agent are loaded per row by the details dialog. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- fresh administrator-only reads of custom audit table. if ( '' === $search ) { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, time_gmt, username, event_type, object_type, object_id, object_title, ip, country, region, city FROM %i ORDER BY id DESC LIMIT 200', $table ), ARRAY_A ); } else { $like = '%' . $wpdb->esc_like( $search ) . '%'; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, time_gmt, username, event_type, object_type, object_id, object_title, ip, country, region, city FROM %i WHERE (event_type LIKE %s OR username LIKE %s OR object_title LIKE %s OR ip LIKE %s) ORDER BY id DESC LIMIT 200', $table, $like, $like, $like, $like ), ARRAY_A ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return is_array( $rows ) ? $rows : array(); } /** * Activity Log screen. * * @since 1.0.0 */ public function render_logs_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only search filter. $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $rows = $this->recent_logs( $search ); $stats = $this->stats(); $retention = (int) get_option( 'log_auditor_retention_days', 90 ); include plugin_dir_path( __FILE__ ) . 'partials/log-auditor-admin-logs.php'; } /** * Settings screen. * * @since 1.0.0 */ public function render_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- status flags set by our own redirects. $saved = ! empty( $_GET['log_auditor_saved'] ); $geo_status = isset( $_GET['log_auditor_geoip_upload'] ) ? sanitize_key( wp_unslash( $_GET['log_auditor_geoip_upload'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $geo_messages = array( 'success' => __( 'GeoIP database installed. New logs can now resolve locations.', 'entangle-log-auditor' ), 'missing' => __( 'No file received', 'entangle-log-auditor' ), 'invalid' => __( 'Select an extracted .mmdb file under 100 MB', 'entangle-log-auditor' ), 'db_invalid' => __( 'Invalid or unreadable GeoLite2-City database', 'entangle-log-auditor' ), 'move_failed' => __( 'Could not move uploaded file into storage', 'entangle-log-auditor' ), 'rename_failed' => __( 'Could not finalize database file', 'entangle-log-auditor' ), 'failed' => __( 'Storage directory is not writable', 'entangle-log-auditor' ), ); include plugin_dir_path( __FILE__ ) . 'partials/log-auditor-admin-settings.php'; } /** * Help Center screen. * * @since 1.0.0 */ public function render_help_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $groups = self::help_faqs(); include plugin_dir_path( __FILE__ ) . 'partials/log-auditor-admin-help.php'; } /** * FAQ entries for the Help Center, grouped under the plugin's documentation headings. * * Answers may contain basic inline markup and are printed through wp_kses_post(). * * @since 1.0.0 * @return array[] slug => [ title, dashicon, items => [ [ question, answer ] ] ]. */ public static function help_faqs() { $settings = esc_url( admin_url( 'admin.php?page=log-auditor-settings' ) ); return array( 'getting-started' => array( 'title' => __( 'Getting started', 'entangle-log-auditor' ), 'icon' => 'dashicons-flag', 'items' => array( array( __( 'How do I set up EC Intelligent Log Auditor after activating it?', 'entangle-log-auditor' ), /* translators: %s: Settings screen URL. */ sprintf( __( 'Logging starts as soon as the plugin is active. Open EC Intelligent Log Auditor › Settings to choose which activity is tracked, how long entries are kept, and how IP addresses are stored.', 'entangle-log-auditor' ), $settings ), ), array( __( 'Who can see the activity log?', 'entangle-log-auditor' ), __( 'Only users with the manage_options capability, which on a standard site means administrators. The same applies to the CSV export, the details dialog and the Settings screen.', 'entangle-log-auditor' ), ), array( __( 'Which WordPress and PHP versions are supported?', 'entangle-log-auditor' ), __( 'WordPress 6.2 or later and PHP 7.4 or later.', 'entangle-log-auditor' ), ), ), ), 'what-gets-logged' => array( 'title' => __( 'What gets logged', 'entangle-log-auditor' ), 'icon' => 'dashicons-list-view', 'items' => array( array( __( 'What activity does EC Intelligent Log Auditor record?', 'entangle-log-auditor' ), __( 'Logins, logouts and failed logins; content created, updated and status changes; user, profile, password, email, role and capability changes; application passwords; plugin and theme activation, deletion and file-editor saves; core, plugin, theme and translation updates; selected WordPress settings; and changes to EC Intelligent Log Auditor itself.', 'entangle-log-auditor' ), ), array( __( 'What details are stored with each entry?', 'entangle-log-auditor' ), __( 'The time (UTC), the user and their role, the event, the affected object, the IP address, the location (when GeoIP is set up), the user agent and the request URI. Before and after values are stored for supported setting changes.', 'entangle-log-auditor' ), ), array( __( 'Why are changes to WordPress settings not showing up?', 'entangle-log-auditor' ), /* translators: %s: Settings screen URL. */ sprintf( __( 'Settings tracking is off by default. Turn on Selected WordPress settings under Settings › What gets tracked. Options that look like secrets are never logged.', 'entangle-log-auditor' ), $settings ), ), array( __( 'Are revisions and autosaves logged?', 'entangle-log-auditor' ), __( 'No. Revisions and autosaves are skipped so the log only shows real content changes. Changes to the page set as the static homepage are flagged.', 'entangle-log-auditor' ), ), array( __( 'Can someone turn the audit trail off without it being noticed?', 'entangle-log-auditor' ), __( 'No. Changes to EC Intelligent Log Auditor\'s own settings and the deactivation of the plugin are always logged, whatever the tracking toggles say.', 'entangle-log-auditor' ), ), array( __( 'Are passwords or tokens ever stored?', 'entangle-log-auditor' ), __( 'Secrets in the URL query string, such as nonces, tokens, passwords and keys, are redacted before the request URI is saved, and secret-looking options are never logged.', 'entangle-log-auditor' ), ), ), ), 'activity-log' => array( 'title' => __( 'Activity Log screen', 'entangle-log-auditor' ), 'icon' => 'dashicons-visibility', 'items' => array( array( __( 'What do the summary cards show?', 'entangle-log-auditor' ), __( 'Events and active users over the last 30 days, failed logins over the last 24 hours, and how long entries are kept before they are deleted.', 'entangle-log-auditor' ), ), array( __( 'How many entries does the Activity Log show?', 'entangle-log-auditor' ), __( 'The latest 200. Use the search box to narrow them down by event, username, object title or IP address.', 'entangle-log-auditor' ), ), array( __( 'How do I see everything recorded for an entry?', 'entangle-log-auditor' ), __( 'Select View on the row. A dialog shows the time, user, event, object, IP address, location and request URI, plus before and after values when they were recorded.', 'entangle-log-auditor' ), ), array( __( 'How do I export the log?', 'entangle-log-auditor' ), __( 'Select Export CSV below the table to download the latest 5,000 entries. Values that a spreadsheet could treat as a formula are escaped.', 'entangle-log-auditor' ), ), array( __( 'Which timezone are the times shown in?', 'entangle-log-auditor' ), __( 'Entries are recorded and shown in UTC.', 'entangle-log-auditor' ), ), ), ), 'privacy-retention' => array( 'title' => __( 'Privacy & retention', 'entangle-log-auditor' ), 'icon' => 'dashicons-privacy', 'items' => array( array( __( 'What do the IP storage options mean?', 'entangle-log-auditor' ), __( 'Full IP stores the address as received. Masked zeroes the last IPv4 octet or the last 64 bits of an IPv6 address. Hashed only stores a keyed HMAC-SHA256 hash, so repeat visits can still be matched without keeping the address.', 'entangle-log-auditor' ), ), array( __( 'What happens to existing entries when I change IP storage?', 'entangle-log-auditor' ), __( 'Switching to masked or hashed also converts the entries already stored, in the background. Masking and hashing cannot be undone, so switching back to full IP only affects new entries.', 'entangle-log-auditor' ), ), array( __( 'How long are entries kept?', 'entangle-log-auditor' ), __( '90 days by default. You can choose anything from 1 to 3,650 days; a daily cleanup task deletes older entries.', 'entangle-log-auditor' ), ), array( __( 'Are failed logins rate limited?', 'entangle-log-auditor' ), __( 'Yes. By default up to 10 failed logins per IP address and 60 site-wide are recorded per minute, followed by a single summary entry when a limit is reached. Developers can change the limits with the log_auditor_failed_login_ip_limit and log_auditor_failed_login_global_limit filters.', 'entangle-log-auditor' ), ), array( __( 'Does EC Intelligent Log Auditor work with the WordPress privacy tools?', 'entangle-log-auditor' ), __( 'Yes. Log entries are included in Tools › Export Personal Data and Erase Personal Data, and suggested text is added to the privacy policy guide.', 'entangle-log-auditor' ), ), ), ), 'proxy-cdn' => array( 'title' => __( 'Sites behind a proxy or CDN', 'entangle-log-auditor' ), 'icon' => 'dashicons-networking', 'items' => array( array( __( 'Why does every entry show the same IP address?', 'entangle-log-auditor' ), __( 'Your site is probably behind a load balancer, reverse proxy or CDN. By default the IP is taken from the connection (REMOTE_ADDR), because forwarding headers can be forged by any visitor, so you will see the proxy\'s address until you list it as trusted.', 'entangle-log-auditor' ), ), array( __( 'How do I tell EC Intelligent Log Auditor which proxies to trust?', 'entangle-log-auditor' ), __( 'Add the proxy addresses or ranges to wp-config.php: define( \'LOG_AUDITOR_TRUSTED_PROXIES\', \'10.0.0.0/8, 2001:db8::/32\' ); The client IP is then read from X-Forwarded-For, skipping trusted hops from the right.', 'entangle-log-auditor' ), ), array( __( 'How do I set it up for Cloudflare?', 'entangle-log-auditor' ), __( 'List Cloudflare\'s IP ranges as trusted proxies, then also add define( \'LOG_AUDITOR_CLIENT_IP_HEADER\', \'HTTP_CF_CONNECTING_IP\' ); to wp-config.php.', 'entangle-log-auditor' ), ), array( __( 'Can I configure this in code instead of wp-config.php?', 'entangle-log-auditor' ), __( 'Yes. Use the log_auditor_trusted_proxies and log_auditor_client_ip_header filters.', 'entangle-log-auditor' ), ), ), ), 'geoip' => array( 'title' => __( 'GeoIP location', 'entangle-log-auditor' ), 'icon' => 'dashicons-location', 'items' => array( array( __( 'Why is the Location column empty?', 'entangle-log-auditor' ), __( 'Location lookups need a GeoLite2-City or GeoIP2-City database from MaxMind, which cannot be bundled with the plugin. Install the database and make sure GeoIP lookup is enabled in Settings.', 'entangle-log-auditor' ), ), array( __( 'How do I install the GeoIP database?', 'entangle-log-auditor' ), /* translators: %s: Settings screen URL. */ sprintf( __( 'Download GeoLite2-City.mmdb from MaxMind under its license terms, extract it, and upload it from Settings › GeoIP database. Uploading a valid database switches lookups on automatically.', 'entangle-log-auditor' ), $settings ), ), array( __( 'The upload fails. What can I do?', 'entangle-log-auditor' ), __( 'The file must be an extracted .mmdb under 100 MB and within the PHP upload and POST limits shown on the Settings screen. If your host\'s limits are lower, copy the file over SFTP to the server path shown there instead.', 'entangle-log-auditor' ), ), array( __( 'Will older entries get a location?', 'entangle-log-auditor' ), __( 'No. Locations are resolved when an entry is recorded, so only activity logged after the database is installed has a location.', 'entangle-log-auditor' ), ), array( __( 'Where is the GeoIP database stored?', 'entangle-log-auditor' ), __( 'In a randomly named wp-content/uploads/log-auditor-xxxxxxxxxxxxxxxx/ directory; the exact path is shown on the Settings screen. It is protected from direct web access on Apache and IIS, and the random name keeps it unguessable elsewhere. On Nginx you can also deny access to /wp-content/uploads/log-auditor- in your server configuration.', 'entangle-log-auditor' ), ), ), ), 'data-uninstall' => array( 'title' => __( 'Deactivating & uninstalling', 'entangle-log-auditor' ), 'icon' => 'dashicons-database', 'items' => array( array( __( 'Does deactivating or deleting the plugin remove my logs?', 'entangle-log-auditor' ), __( 'No. Deactivation and uninstall stop the scheduled tasks, but the log table and settings are kept so the audit history survives a reinstall.', 'entangle-log-auditor' ), ), array( __( 'Is deactivating the plugin recorded?', 'entangle-log-auditor' ), __( 'Yes. Deactivating EC Intelligent Log Auditor is always logged, so the gap in the audit trail is visible.', 'entangle-log-auditor' ), ), ), ), ); } /** * Admin-post handler for the Settings form. * * @since 1.0.0 */ public function save_settings() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Permission denied.', 'entangle-log-auditor' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'log_auditor_save_settings' ); foreach ( array_keys( self::tracking_options() ) as $option ) { update_option( $option, empty( $_POST[ $option ] ) ? 0 : 1 ); } update_option( 'log_auditor_geoip_enabled', empty( $_POST['log_auditor_geoip_enabled'] ) ? 0 : 1, true ); update_option( 'log_auditor_geoip_user_choice_made', 1, true ); $retention = isset( $_POST['log_auditor_retention_days'] ) ? absint( $_POST['log_auditor_retention_days'] ) : 90; update_option( 'log_auditor_retention_days', min( 3650, max( 1, $retention ) ) ); $ip_storage = isset( $_POST['log_auditor_ip_storage'] ) ? sanitize_key( wp_unslash( $_POST['log_auditor_ip_storage'] ) ) : 'full'; $ip_storage = in_array( $ip_storage, array( 'full', 'masked', 'hashed' ), true ) ? $ip_storage : 'full'; if ( (string) get_option( 'log_auditor_ip_storage', 'full' ) !== $ip_storage ) { Log_Auditor_Privacy::schedule_ip_reprocess( $ip_storage ); } update_option( 'log_auditor_ip_storage', $ip_storage ); wp_safe_redirect( add_query_arg( 'log_auditor_saved', 1, admin_url( 'admin.php?page=log-auditor-settings' ) ) ); exit; } /** * AJAX: HTML fragment for the log details modal. * * @since 1.0.0 */ public function ajax_log_details() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Forbidden', 'entangle-log-auditor' ), '', array( 'response' => 403 ) ); } check_ajax_referer( 'log_auditor_log_details', 'nonce' ); $row = Log_Auditor_DB::get_log( isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0 ); if ( ! $row ) { wp_die( esc_html__( 'Not found', 'entangle-log-auditor' ), '', array( 'response' => 404 ) ); } include plugin_dir_path( __FILE__ ) . 'partials/log-auditor-admin-log-details.php'; exit; } /** * Admin-post handler that streams the latest 5,000 rows as CSV. * * @since 1.0.0 */ public function export_csv() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Forbidden', 'entangle-log-auditor' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'log_auditor_export_csv' ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- fresh administrator-authorized export of custom audit records. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,time_gmt,user_id,username,role,event_type,object_type,object_id,object_title,ip,ip_hash,country,region,city,request_uri FROM %i ORDER BY id DESC LIMIT 5000', Log_Auditor_DB::table_name() ), ARRAY_A ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=log-auditor.csv' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streamed CSV download. if ( $rows ) { fputcsv( $out, array_keys( $rows[0] ) ); foreach ( $rows as $row ) { fputcsv( $out, array_map( array( __CLASS__, 'csv_safe' ), $row ) ); } } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- close download stream. exit; } /** * Stop spreadsheet apps from treating logged text (e.g. a post title) as a formula. * * @param mixed $value Cell value. * @return mixed */ private static function csv_safe( $value ) { if ( is_string( $value ) && '' !== $value && false !== strpos( "=+-@\t\r", $value[0] ) ) { return "'" . $value; } return $value; } /** * Admin-post handler that validates and installs an uploaded GeoLite2/GeoIP2 City MMDB file. * * @since 1.0.0 */ public function upload_geoip() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Permission denied.', 'entangle-log-auditor' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'log_auditor_upload_geoip' ); $back = admin_url( 'admin.php?page=log-auditor-settings' ); $redirect = static function ( $status ) use ( $back ) { wp_safe_redirect( add_query_arg( 'log_auditor_geoip_upload', $status, $back ) ); exit; }; if ( empty( $_FILES['log_auditor_geoip_database'] ) || ! is_array( $_FILES['log_auditor_geoip_database'] ) ) { $redirect( 'missing' ); } // Passed to wp_handle_upload() unmodified: it checks is_uploaded_file() and sanitizes the // name itself, and unslashing tmp_name would break Windows paths. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $file = $_FILES['log_auditor_geoip_database']; foreach ( array( 'name', 'tmp_name' ) as $key ) { if ( ! isset( $file[ $key ] ) || ! is_string( $file[ $key ] ) ) { $redirect( 'missing' ); } } $file['size'] = isset( $file['size'] ) ? absint( $file['size'] ) : 0; $file['error'] = isset( $file['error'] ) && is_scalar( $file['error'] ) ? absint( $file['error'] ) : UPLOAD_ERR_NO_FILE; if ( 'mmdb' !== strtolower( pathinfo( sanitize_file_name( $file['name'] ), PATHINFO_EXTENSION ) ) || $file['size'] < 100 || $file['size'] > 104857600 ) { $redirect( 'invalid' ); } require_once ABSPATH . 'wp-admin/includes/file.php'; $uploaded = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => array( 'mmdb' => 'application/octet-stream' ), 'test_type' => false, ) ); if ( ! empty( $uploaded['error'] ) || empty( $uploaded['file'] ) ) { $redirect( 'failed' ); } $staging = $uploaded['file']; $info = Log_Auditor_GeoIP::inspect_database( $staging ); if ( empty( $info['valid'] ) || ! in_array( $info['database_type'], array( 'GeoLite2-City', 'GeoIP2-City' ), true ) ) { wp_delete_file( $staging ); $redirect( 'db_invalid' ); } $target = Log_Auditor_GeoIP::managed_mmdb_path(); if ( ! Log_Auditor_DB::ensure_data_dir() ) { wp_delete_file( $staging ); $redirect( 'move_failed' ); } $fs = Log_Auditor_DB::filesystem(); if ( ! $fs->move( $staging, $target, true ) ) { wp_delete_file( $staging ); $redirect( 'rename_failed' ); } $fs->chmod( $target, 0640 ); update_option( 'log_auditor_geoip_enabled', 1, true ); update_option( 'log_auditor_geoip_mmdb_path', '', false ); Log_Auditor_GeoIP::reset(); $redirect( 'success' ); } }