prefix . 'quietadmin_events'; } /** * UTC timestamp as MySQL date (for range queries). * * @since 0.1.0 * * @param int $timestamp Unix timestamp. * @return string */ public static function format_utc( $timestamp ) { return gmdate( 'Y-m-d H:i:s', (int) $timestamp ); } /** * MySQL date (UTC) X seconds ago. * * @since 0.1.0 * * @param int $seconds Seconds into the past. * @return string */ public static function utc_before( $seconds ) { return self::format_utc( time() - absint( $seconds ) ); } /** * Converts a stored UTC date into a Unix timestamp. * * @since 0.1.0 * * @param string $mysql_date Date from the table. * @return int */ public static function to_timestamp( $mysql_date ) { $timestamp = mysql2date( 'U', (string) $mysql_date, false ); return $timestamp ? (int) $timestamp : 0; } /** * Writes an event. * * @since 0.1.0 * * @param string $type Event type (one of the class constants). * @param string $event Machine-readable key, e.g. 'plugin_updated'. * @param string $title Human-readable description (German). * @param array $meta Arbitrary additional data (JSON-encoded). * @return int|false Insert ID. */ public static function add( $type, $event, $title = '', $meta = array() ) { global $wpdb; $type = sanitize_key( (string) $type ); $event = sanitize_key( (string) $event ); /** * Filter or suppress the event before saving. * * @since 0.1.0 * * @param array|false $log Data, or false to skip. */ $log = apply_filters( 'quietadmin_log_event', array( 'event_type' => $type, 'event' => $event, 'title' => sanitize_text_field( (string) $title ), 'user_id' => get_current_user_id(), 'meta' => wp_json_encode( $meta ), 'created_at' => gmdate( 'Y-m-d H:i:s' ), // UTC. ), $type, $event, $meta ); if ( ! is_array( $log ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectInsert, WordPress.DB.DirectDatabaseQuery.DirectQuery -- Own log table, no schema caching possible. $inserted = $wpdb->insert( self::table(), $log ); if ( false === $inserted ) { return false; } // Note: Events deliberately no longer reset the "last check" timestamp. // A log entry (e.g. a failed login) // is not a system check; the timestamp is set exclusively by // StatusLights::run_checks(). return (int) $wpdb->insert_id; } /** * Entries that refer to a user (author or reference in meta). * * Used by the WordPress privacy tools (export/erasure). * * @since 0.1.2 * * @param int $user_id User ID. * @param int $limit Maximum number. * @param int $offset Offset. * @return object[] */ public static function rows_for_user( $user_id, $limit = 50, $offset = 0 ) { global $wpdb; $user_id = absint( $user_id ); if ( ! $user_id ) { return array(); } $table = self::table(); $pattern = '"(' . implode( '|', Privacy::META_USER_KEYS ) . ')":' . $user_id . '([,}])'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. $wpdb->prepare( "SELECT id, event_type, event, title, user_id, meta, created_at FROM {$table} WHERE user_id = %d OR meta REGEXP %s ORDER BY id ASC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $user_id, $pattern, absint( $limit ), absint( $offset ) ) ); } /** * Removes the user reference from entries (user ID to 0). * * @since 0.1.2 * * @param object[] $rows Rows from rows_for_user(). * @param int $user_id User ID. * @return int Number of anonymized rows. */ public static function anonymize_rows( $rows, $user_id ) { global $wpdb; $user_id = absint( $user_id ); $done = 0; foreach ( (array) $rows as $row ) { $meta = json_decode( (string) $row->meta, true ); $meta = is_array( $meta ) ? $meta : array(); foreach ( Privacy::META_USER_KEYS as $key ) { if ( isset( $meta[ $key ] ) && (int) $meta[ $key ] === $user_id ) { $meta[ $key ] = 0; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $updated = $wpdb->update( self::table(), array( 'user_id' => (int) $row->user_id === $user_id ? 0 : (int) $row->user_id, 'meta' => wp_json_encode( $meta ), ), array( 'id' => (int) $row->id ), array( '%d', '%s' ), array( '%d' ) ); if ( false !== $updated ) { ++$done; } } return $done; } /** * Time of the last event of a type. * * @since 0.1.1 * * @param string $type Event type, empty for all. * @return int Unix timestamp (UTC) or 0. */ public static function last_event_time( $type = '' ) { global $wpdb; $table = self::table(); if ( '' !== $type ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $created_at = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. $wpdb->prepare( "SELECT created_at FROM {$table} WHERE event_type = %s ORDER BY created_at DESC, id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared sanitize_key( $type ) ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $created_at = $wpdb->get_var( "SELECT created_at FROM {$table} ORDER BY created_at DESC, id DESC LIMIT 1" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. } return $created_at ? self::to_timestamp( $created_at ) : 0; } /** * Counts events with specific keys in a time range. * * @since 0.1.1 * * @param string[] $events Event keys. * @param string $since MySQL date (UTC). * @param string $until MySQL date (UTC). * @return int */ public static function count_events( $events, $since = '', $until = '' ) { global $wpdb; $events = array_values( array_filter( array_map( 'sanitize_key', (array) $events ) ) ); if ( empty( $events ) ) { return 0; } $table = self::table(); $placeholders = implode( ', ', array_fill( 0, count( $events ), '%s' ) ); $where = array( "event IN ({$placeholders})" ); $args = $events; if ( '' !== $since ) { $where[] = 'created_at >= %s'; $args[] = $since; } if ( '' !== $until ) { $where[] = 'created_at <= %s'; $args[] = $until; } $sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $where ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var( $wpdb->prepare( $sql, $args ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. } /** * Events of a time range. * * @since 0.1.0 * * @param string $since MySQL date (UTC). * @param string $until MySQL date (UTC). * @param int $limit Maximum number. * @return object[] */ public static function get_events( $since, $until, $limit = 500 ) { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. $wpdb->prepare( "SELECT event_type, event, title, user_id, created_at FROM {$table} WHERE created_at >= %s AND created_at <= %s ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $since, $until, absint( $limit ) ) ); } /** * Counts events of a type in a time range. * * @since 0.1.0 * * @param string $type Event type, empty for all. * @param string $since MySQL date (UTC). * @param string $until MySQL date (UTC). * @return int */ public static function count( $type = '', $since = '', $until = '' ) { global $wpdb; $table = self::table(); $where = array( '1=1' ); $args = array(); if ( '' !== $type ) { $where[] = 'event_type = %s'; $args[] = sanitize_key( $type ); } if ( '' !== $since ) { $where[] = 'created_at >= %s'; $args[] = $since; } if ( '' !== $until ) { $where[] = 'created_at <= %s'; $args[] = $until; } $sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $where ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared if ( ! empty( $args ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var( $wpdb->prepare( $sql, $args ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. } // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. } /** * Grouped count by event key. * * @since 0.1.0 * * @param string $since MySQL date (UTC). * @param string $until MySQL date (UTC). * @return array */ public static function counts_by_event( $since, $until ) { global $wpdb; $table = self::table(); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. $wpdb->prepare( "SELECT event, COUNT(*) AS total FROM {$table} WHERE created_at >= %s AND created_at <= %s GROUP BY event ORDER BY total DESC LIMIT 50", $since, $until ) ); $counts = array(); foreach ( (array) $rows as $row ) { $counts[ $row->event ] = (int) $row->total; } // Bulk plugin/theme updates are stored as one row with a component count. // Read only those low-volume rows and replace their row totals with the // actual number of updated components. $weighted_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. $wpdb->prepare( "SELECT event, meta FROM {$table} WHERE event IN (%s, %s) AND created_at >= %s AND created_at <= %s", 'plugin_updated', 'theme_updated', $since, $until ) ); // phpcs:enable foreach ( array( 'plugin_updated', 'theme_updated' ) as $weighted_event ) { $counts[ $weighted_event ] = 0; } foreach ( (array) $weighted_rows as $row ) { $event = sanitize_key( (string) $row->event ); $meta = json_decode( (string) $row->meta, true ); $count = is_array( $meta ) && isset( $meta['count'] ) ? max( 1, absint( $meta['count'] ) ) : 1; $counts[ $event ] += $count; } return $counts; } /** * Delete older events (runs daily via cron). * * @since 0.1.0 * * @param int $days Retention period in days. * @return int Number of deleted rows. */ public static function prune( $days = 180 ) { global $wpdb; $days = max( 7, absint( $days ) ); $table = self::table(); $cutoff = self::format_utc( time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix; values are prepared. $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); // phpcs:enable return (int) $deleted; } }