prefix . 'ability_guard_logs'; } /** * Insert a log row. * * @param array $data Log payload. * @return int Inserted row ID. */ public function insert( array $data ): int { global $wpdb; $data = apply_filters( 'ability_guard_before_log_insert', $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $inserted = $wpdb->insert( $this->get_table_name(), array( 'ability_name' => $data['ability_name'] ?? '', 'user_id' => $data['user_id'] ?? 0, 'triggered_via' => $data['triggered_via'] ?? 'php', 'input_data' => $data['input_data'] ?? null, 'output_data' => $data['output_data'] ?? null, 'status' => $data['status'] ?? 'success', 'error_code' => $data['error_code'] ?? null, 'execution_ms' => $data['execution_ms'] ?? 0, 'ip_address' => $data['ip_address'] ?? null, 'created_at' => $data['created_at'] ?? current_time( 'mysql', true ), ), array( '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ) ); if ( false === $inserted ) { return 0; } $insert_id = (int) $wpdb->insert_id; do_action( 'ability_guard_after_log_insert', $insert_id, $data ); return $insert_id; } /** * Get paginated logs. * * @param array $args Query args. * @return array */ public function get_logs( array $args = array() ): array { global $wpdb; $args = wp_parse_args( $args, array( 'per_page' => 20, 'page' => 1, 'orderby' => 'created_at', 'order' => 'DESC', 'ability_name' => '', 'status' => '', 'triggered_via' => '', 'user_id' => '', 'date_from' => '', 'date_to' => '', 'ability_names' => null, 'search' => '', ) ); $table = esc_sql( $this->get_table_name() ); $where = array( '1=1' ); $params = array(); $allowed = array( 'id', 'ability_name', 'user_id', 'triggered_via', 'status', 'execution_ms', 'created_at' ); if ( '' !== $args['ability_name'] ) { $where[] = 'ability_name = %s'; $params[] = sanitize_text_field( $args['ability_name'] ); } if ( '' !== $args['status'] ) { $where[] = 'status = %s'; $params[] = sanitize_key( $args['status'] ); } if ( '' !== $args['triggered_via'] ) { $where[] = 'triggered_via = %s'; $params[] = sanitize_key( $args['triggered_via'] ); } if ( '' !== $args['user_id'] ) { $where[] = 'user_id = %d'; $params[] = absint( $args['user_id'] ); } if ( '' !== $args['date_from'] ) { $date_from = strtotime( sanitize_text_field( $args['date_from'] ) ); if ( false !== $date_from ) { $where[] = 'created_at >= %s'; $params[] = gmdate( 'Y-m-d 00:00:00', $date_from ); } } if ( '' !== $args['date_to'] ) { $date_to = strtotime( sanitize_text_field( $args['date_to'] ) ); if ( false !== $date_to ) { $where[] = 'created_at <= %s'; $params[] = gmdate( 'Y-m-d 23:59:59', $date_to ); } } if ( null !== $args['ability_names'] && is_array( $args['ability_names'] ) ) { $ability_names = array_values( array_filter( array_map( 'sanitize_text_field', $args['ability_names'] ) ) ); if ( empty( $ability_names ) ) { $where[] = '0=1'; } else { $placeholders = implode( ',', array_fill( 0, count( $ability_names ), '%s' ) ); $where[] = "ability_name IN ({$placeholders})"; $params = array_merge( $params, $ability_names ); } } if ( '' !== $args['search'] ) { $where[] = 'ability_name LIKE %s'; $params[] = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%'; } $orderby = in_array( $args['orderby'], $allowed, true ) ? $args['orderby'] : 'created_at'; $order = 'ASC' === strtoupper( (string) $args['order'] ) ? 'ASC' : 'DESC'; $offset = max( 0, ( absint( $args['page'] ) - 1 ) * absint( $args['per_page'] ) ); $sql = "SELECT * FROM {$table} WHERE " . implode( ' AND ', $where ) . " ORDER BY {$orderby} {$order}, id {$order} LIMIT %d OFFSET %d"; $params[] = absint( $args['per_page'] ); $params[] = $offset; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared return $wpdb->get_results( $wpdb->prepare( $sql, $params ) ); } /** * Count logs matching filters. * * @param array $args Query args. * @return int */ public function count_logs( array $args = array() ): int { global $wpdb; $args = wp_parse_args( $args, array( 'ability_name' => '', 'status' => '', 'triggered_via' => '', 'user_id' => '', 'date_from' => '', 'date_to' => '', 'ability_names' => null, 'search' => '', ) ); $table = esc_sql( $this->get_table_name() ); $where = array( '1=1' ); $params = array(); if ( '' !== $args['ability_name'] ) { $where[] = 'ability_name = %s'; $params[] = sanitize_text_field( $args['ability_name'] ); } if ( '' !== $args['status'] ) { $where[] = 'status = %s'; $params[] = sanitize_key( $args['status'] ); } if ( '' !== $args['triggered_via'] ) { $where[] = 'triggered_via = %s'; $params[] = sanitize_key( $args['triggered_via'] ); } if ( '' !== $args['user_id'] ) { $where[] = 'user_id = %d'; $params[] = absint( $args['user_id'] ); } if ( '' !== $args['date_from'] ) { $date_from = strtotime( sanitize_text_field( $args['date_from'] ) ); if ( false !== $date_from ) { $where[] = 'created_at >= %s'; $params[] = gmdate( 'Y-m-d 00:00:00', $date_from ); } } if ( '' !== $args['date_to'] ) { $date_to = strtotime( sanitize_text_field( $args['date_to'] ) ); if ( false !== $date_to ) { $where[] = 'created_at <= %s'; $params[] = gmdate( 'Y-m-d 23:59:59', $date_to ); } } if ( null !== $args['ability_names'] && is_array( $args['ability_names'] ) ) { $ability_names = array_values( array_filter( array_map( 'sanitize_text_field', $args['ability_names'] ) ) ); if ( empty( $ability_names ) ) { $where[] = '0=1'; } else { $placeholders = implode( ',', array_fill( 0, count( $ability_names ), '%s' ) ); $where[] = "ability_name IN ({$placeholders})"; $params = array_merge( $params, $ability_names ); } } if ( '' !== $args['search'] ) { $where[] = 'ability_name LIKE %s'; $params[] = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%'; } $sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $where ); if ( empty( $params ) ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (int) $wpdb->get_var( $sql ); } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var( $wpdb->prepare( $sql, $params ) ); } /** * Get a single log by ID. * * @param int $id Log ID. * @return object|null */ public function get_log( int $id ): ?object { global $wpdb; $table = esc_sql( $this->get_table_name() ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", absint( $id ) ) ); return null === $row ? null : $row; } /** * Delete selected logs. * * @param array $ids Log IDs. * @return int|false */ public function delete_logs( array $ids ) { global $wpdb; $ids = array_values( array_filter( array_map( 'absint', $ids ) ) ); if ( empty( $ids ) ) { return 0; } $table = esc_sql( $this->get_table_name() ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare return $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids ) ); } /** * Get logs associated with a user for WordPress personal data exports. * * @param int $user_id User ID. * @param int $page Page number. * @param int $per_page Rows to return. * @return array */ public function get_user_privacy_logs( int $user_id, int $page = 1, int $per_page = 50 ): array { global $wpdb; $table = esc_sql( $this->get_table_name() ); $user_id = absint( $user_id ); $per_page = max( 1, absint( $per_page ) ); $offset = max( 0, ( max( 1, absint( $page ) ) - 1 ) * $per_page ); if ( 0 === $user_id ) { return array(); } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return $wpdb->get_results( $wpdb->prepare( "SELECT id, ability_name, triggered_via, status, error_code, execution_ms, ip_address, created_at FROM {$table} WHERE user_id = %d ORDER BY created_at ASC, id ASC LIMIT %d OFFSET %d", $user_id, $per_page, $offset ) ); } /** * Remove personal fields from logs for a user while preserving audit rows. * * @param int $user_id User ID. * @return int Number of affected rows. */ public function anonymize_user_logs( int $user_id ): int { global $wpdb; $user_id = absint( $user_id ); if ( 0 === $user_id ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $wpdb->update( $this->get_table_name(), array( 'user_id' => 0, 'input_data' => null, 'output_data' => null, 'ip_address' => null, ), array( 'user_id' => $user_id ), array( '%d', '%s', '%s', '%s' ), array( '%d' ) ); return false === $result ? 0 : (int) $result; } /** * Enforce rolling log retention. * * @param int $limit Maximum rows to retain. */ public function enforce_limit( int $limit ): void { global $wpdb; if ( $limit <= 0 ) { return; } $table = esc_sql( $this->get_table_name() ); $count = $this->count_logs(); if ( $count <= $limit ) { return; } $excess = $count - $limit; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} ORDER BY created_at ASC, id ASC LIMIT %d", $excess ) ); } }