= $retention_days; } /** * Return a valid retention period or the documented default. * * @param mixed $days Candidate number of days. */ public static function retention_days_or_default( $days ): int { return self::has_valid_retention_days( $days ) ? (int) $days : self::DEFAULT_RETENTION_DAYS; } /** * Build the WHERE clause fragment and its parameters from filter args. * * Public since the network log (#8): Network\NetworkLog reads the * same filters over its own table through this one clause, so the two * screens cannot validate a filter differently. It opens with * " WHERE 1=1" and names only columns both tables have. * * @param array $args Filter arguments (user_id, user_email, event_type, object_type, object_id, date_from, date_to, search, country, device_class, marker). * @return array{0: string, 1: array} SQL fragment and parameters. */ public static function build_where( array $args ): array { global $wpdb; $where = ' WHERE 1=1'; $params = array(); // Free text: anywhere in the summary, who, from where, or the // details JSON. The term is matched literally (esc_like), so % and // _ in it are not wildcards. $search = isset( $args['search'] ) ? trim( (string) $args['search'] ) : ''; if ( '' !== $search ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; $where .= ' AND ( summary LIKE %s OR user_email LIKE %s OR ip_address LIKE %s OR details LIKE %s )'; $params[] = $like; $params[] = $like; $params[] = $like; $params[] = $like; } if ( null !== $args['user_id'] ) { $where .= ' AND user_id = %d'; $params[] = (int) $args['user_id']; } if ( null !== $args['user_email'] ) { $where .= ' AND user_email = %s'; $params[] = (string) $args['user_email']; } if ( null !== $args['event_type'] ) { $where .= ' AND event_type = %s'; $params[] = (string) $args['event_type']; } if ( null !== $args['object_type'] ) { $where .= ' AND object_type = %s'; $params[] = (string) $args['object_type']; } if ( null !== $args['object_id'] ) { $where .= ' AND object_id = %d'; $params[] = (int) $args['object_id']; } // The request context (#7). Validated here, not only by callers: a // country is two capital letters and a device class one of four, so // anything else is ignored rather than matched against. $country = isset( $args['country'] ) ? (string) $args['country'] : ''; if ( 1 === preg_match( '/^[A-Z]{2}$/', $country ) ) { $where .= ' AND country = %s'; $params[] = $country; } $device_class = isset( $args['device_class'] ) ? (string) $args['device_class'] : ''; if ( in_array( $device_class, Context\UserAgent::CLASSES, true ) ) { $where .= ' AND device_class = %s'; $params[] = $device_class; } // A login marker (#7 and the login-alerts slice), for the scripted // surfaces. Markers live in a login row's details, so this is a // login-only predicate on the stored JSON: one of the closed list, // matched literally (esc_like: the names carry underscores), never // a pattern a caller supplies. $fragment = isset( $args['marker'] ) && is_string( $args['marker'] ) ? Context\LoginSignal::stored_fragment( $args['marker'] ) : null; if ( null !== $fragment ) { $where .= ' AND event_type = %s AND details LIKE %s'; $params[] = 'user_login'; $params[] = '%' . $wpdb->esc_like( $fragment ) . '%'; } if ( null !== $args['date_from'] ) { $from = self::date_bound( 'date_from', (string) $args['date_from'] ); if ( null !== $from ) { $where .= ' AND timestamp >= %s'; $params[] = $from; } } if ( null !== $args['date_to'] ) { $to = self::date_bound( 'date_to', (string) $args['date_to'] ); if ( null !== $to ) { $where .= ' AND timestamp <= %s'; $params[] = $to; } } return array( $where, $params ); } /** * The stored-timestamp bound a date filter means, or null when the text * is not a date. date_from is the moment the text names; date_to is the * end of the day it names, so a bare date takes in that whole day. * * Public so the scripted surfaces can refuse a date this would drop: * build_where() ignores a bound it cannot read, which on a screen is a * shrug and in a script is the whole log where a week was meant. * * @param string $key 'date_from' or 'date_to'. * @param string $value The filter's text: a date, or a phrase strtotime() reads. */ public static function date_bound( string $key, string $value ): ?string { $ts = strtotime( 'date_to' === $key ? $value . ' 23:59:59' : $value ); return false === $ts ? null : gmdate( 'Y-m-d H:i:s', $ts ); } /** * Get activity log events with optional filtering. * * @param array $args Query arguments. * @return array> Array of events. */ public static function get_events( array $args = array() ): array { global $wpdb; $defaults = array( 'user_id' => null, 'user_email' => null, 'event_type' => null, 'object_type' => null, 'object_id' => null, 'date_from' => null, 'date_to' => null, 'search' => null, 'country' => null, 'device_class' => null, 'marker' => null, 'per_page' => 20, 'page' => 1, ); $args = wp_parse_args( $args, $defaults ); list( $where, $params ) = self::build_where( $args ); $query = 'SELECT * FROM ' . Database::get_table_name() . $where . ' ORDER BY timestamp DESC, id DESC LIMIT %d, %d'; $offset = ( max( 1, (int) $args['page'] ) - 1 ) * (int) $args['per_page']; $params[] = $offset; $params[] = (int) $args['per_page']; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( $wpdb->prepare( $query, $params ), ARRAY_A ); return self::without_chain( $rows ); } /** * Every event a person appears in, for their personal-data export and * erasure: those logged under their email address, those logged under * their account whatever its email was at the time, and those done to * their account. Oldest first, so paging through them stays stable while * new events are logged. * * Each row carries `matched_email` (1 or 0): whether the database itself * matched its user_email to $email. That keeps the database's own * comparison (case-insensitive under MySQL's collations) as the single * answer to "was this logged under their email?". * * @param string $email The person's email address. * @param int|null $user_id Their account, when it still exists. * @param int $per_page Rows per page. * @param int $page Page number, 1-based. * @return array> */ public static function get_events_for_person( string $email, ?int $user_id, int $per_page, int $page ): array { global $wpdb; $select = 'SELECT *, ( user_email = %s ) AS matched_email FROM ' . Database::get_table_name(); $offset = ( max( 1, $page ) - 1 ) * $per_page; if ( null === $user_id ) { $query = $select . ' WHERE user_email = %s ORDER BY id ASC LIMIT %d, %d'; $params = array( $email, $email, $offset, $per_page ); } else { $query = $select . " WHERE user_email = %s OR user_id = %d OR ( object_type = 'user' AND object_id = %d ) ORDER BY id ASC LIMIT %d, %d"; $params = array( $email, $email, $user_id, $user_id, $offset, $per_page ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( $wpdb->prepare( $query, $params ), ARRAY_A ); return self::without_chain( $rows ); } /** * How many rows get_events_for_person() would return for this person, in * all -- the same WHERE clause, kept in lockstep with it deliberately * rather than derived, since a COUNT(*) and a paged SELECT can't share * one prepared query. Used by PersonalData::export() to know how many * pages that primary source occupies before it starts paging into the * secondary, post-author-reference source (see * get_post_author_reference_candidates()) -- issue #2's eraser/exporter * gap: a row where someone ELSE changed this person's post authorship. * * @param string $email The person's email address. * @param int|null $user_id Their account, when it still exists. */ public static function count_events_for_person( string $email, ?int $user_id ): int { global $wpdb; if ( null === $user_id ) { $query = 'SELECT COUNT(*) FROM ' . Database::get_table_name() . ' WHERE user_email = %s'; $params = array( $email ); } else { $query = 'SELECT COUNT(*) FROM ' . Database::get_table_name() . " WHERE user_email = %s OR user_id = %d OR ( object_type = 'user' AND object_id = %d )"; $params = array( $email, $user_id, $user_id ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) ); } /** * Candidate post_updated rows whose details JSON might name a person as * a post's author, before or after, in an edit somebody ELSE performed * -- the one place a post_updated row's details can hold an identity * that isn't its own actor's. A coarse, SQL-only pre-filter: every * returned row's details contains the literal substring `"author"`, * which this plugin's own detail keys (see * EventLogger::diff_post_fields()) only ever produce via the `author` * key itself -- confirming a candidate actually names the person in * question, rather than merely containing that substring some other * way (a title or excerpt whose value happens to be exactly the word * "author"), is the caller's job, in PHP, per row. * * Paged the same way get_events_for_person() is (oldest first, bounded * LIMIT/OFFSET), so a caller walking every page the same way never * scans the table unbounded. * * @param int $per_page Rows per page. * @param int $page Page number, 1-based. * @return array> */ public static function get_post_author_reference_candidates( int $per_page, int $page ): array { global $wpdb; $like = '%' . $wpdb->esc_like( '"author"' ) . '%'; $offset = ( max( 1, $page ) - 1 ) * $per_page; $query = 'SELECT * FROM ' . Database::get_table_name() . ' WHERE event_type = %s AND details LIKE %s ORDER BY id ASC LIMIT %d, %d'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( $wpdb->prepare( $query, 'post_updated', $like, $offset, $per_page ), ARRAY_A ); return self::without_chain( $rows ); } /** * Count total events. * * @param array $args Filter arguments. * @return int Total count. */ public static function count_events( array $args = array() ): int { global $wpdb; $defaults = array( 'user_id' => null, 'user_email' => null, 'event_type' => null, 'object_type' => null, 'object_id' => null, 'date_from' => null, 'date_to' => null, 'search' => null, 'country' => null, 'device_class' => null, 'marker' => null, ); $args = wp_parse_args( $args, $defaults ); list( $where, $params ) = self::build_where( $args ); $query = 'SELECT COUNT(*) FROM ' . Database::get_table_name() . $where; if ( count( $params ) > 0 ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var( $query ); } /** * The countries the log holds rows from, for the log's country filter: * ISO codes, sorted, at most 250 (there are 249 codes). * * @return array */ public static function countries(): array { global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- the plugin's own table name; no input. $codes = $wpdb->get_col( 'SELECT DISTINCT country FROM ' . Database::get_table_name() . ' WHERE country IS NOT NULL ORDER BY country LIMIT 250' ); return array_values( array_filter( array_map( 'strval', (array) $codes ), static fn( string $c ): bool => 1 === preg_match( '/^[A-Z]{2}$/', $c ) ) ); } /** * Get available event types. * * @return array */ public static function get_event_types(): array { return array( 'user_login' => 'User Login', 'user_logout' => 'User Logout', 'login_failed' => 'Failed Login', 'user_created' => 'User Created', 'user_deleted' => 'User Deleted', 'user_role_changed' => 'User Role Changed', 'post_created' => 'Post Created', 'post_updated' => 'Post Updated', 'post_trashed' => 'Post Trashed', 'post_restored' => 'Post Restored', 'post_deleted' => 'Post Deleted', 'plugin_activated' => 'Plugin Activated', 'plugin_deactivated' => 'Plugin Deactivated', 'theme_switched' => 'Theme Switched', 'setting_changed' => 'Setting Changed', // #7: what every later row records about its request. 'context_settings_changed' => 'Location/Device Settings Changed', ) + Sessions\SessionLog::event_types() + Events\SoftwareEvents::event_types() + Events\AccountEvents::event_types() + Events\AppearanceEvents::event_types() + Events\MediaEvents::event_types() + Events\ToolsEvents::event_types() + Import\Importer::event_types() + Integrity\Scanner::event_types() + Chain\Verifier::event_types() // Listed only where they can happen; rows already written keep // their type and read by it either way. + ( Events\WooCommerceEvents::is_active() ? Events\WooCommerceEvents::event_types() : array() ) + Events\Integrations::event_types(); } /** * Delete events older than a certain number of days. * * @param int $days Number of days to keep. * @return int Number of deleted rows. */ public static function purge_old_events( int $days = self::DEFAULT_RETENTION_DAYS ): int { global $wpdb; // Inside the purge, not at its call site: see retention_unlimited(). if ( self::retention_unlimited() ) { return 0; } $days = self::retention_days_or_default( $days ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); // Through the chain's pruner, which records the removed run before // the DELETE so verification can tell retention from tampering. $result = Chain\Pruner::delete( null, $cutoff ); // The network log (#8): this site's copies go with its rows, and on // the main site the network rows follow its retention. A no-op // unless the plugin is network-active. Network\NetworkLog::purge_with_site( $cutoff ); return (int) $result; } /** * Update arbitrary columns on a single event row by id. The generic * primitive the personal-data eraser (Privacy\PersonalData::erase()) * builds its own event-type-aware redaction on top of: what needs * clearing differs by whether a row's subject was the acting user, the * affected object, or both, and by event type (a role-change's summary * embeds a username; a plugin-activation's does not) — logic that * belongs in the privacy layer, not baked into a single blanket query * here. Uses $wpdb->update() (not a hand-built query) since every call * targets exactly one row by id. * * @param int $id Event row id. * @param array $fields Column => new value. * @return bool True if a value was written. */ public static function update_event( int $id, array $fields ): bool { global $wpdb; if ( array() === $fields ) { return false; } // A privacy erasure rewrites what the chain sealed: the row is marked // so verification compares its place in the chain but not its content // (Chain\Walk). Written in the same UPDATE, never a second one a // failure could separate from the first. $result = $wpdb->update( Database::get_table_name(), $fields + array( 'chain_redacted' => 1 ), array( 'id' => $id ) ); // The network log's copy of the row (#8) takes the same change, so an // erasure on a site reaches the network screen too. if ( false !== $result ) { Network\NetworkLog::mirror_update( $id, $fields ); } return false !== $result; } /** * Rows without the chain's bookkeeping columns: three 64-character * digests and a flag no reader of the log (a screen, an export, the Pro * add-on's query seam, the personal-data exporter) has any use for, and * which the documented row shape never had. * * @param mixed $rows The result of a SELECT. * @return array> */ private static function without_chain( $rows ): array { if ( ! is_array( $rows ) ) { return array(); } foreach ( $rows as $index => $row ) { if ( is_array( $row ) ) { unset( $row['chain_prev'], $row['chain_content'], $row['chain_hash'], $row['chain_redacted'] ); $rows[ $index ] = $row; } } return $rows; } }