prefix . 'aipcf_activity'; } public static function itemsTable(): string { global $wpdb; return $wpdb->prefix . 'aipcf_activity_items'; } public static function installed(): bool { return (int) get_option(self::VERSION_OPTION) === self::DB_VERSION; } public static function install(): void { if (self::installed()) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $table = self::table(); $items = self::itemsTable(); dbDelta("CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ts datetime NOT NULL, actor varchar(8) NOT NULL, user_id bigint(20) unsigned NOT NULL DEFAULT 0, tool varchar(20) NOT NULL, action varchar(40) NOT NULL, status varchar(8) NOT NULL DEFAULT 'ok', message varchar(500) NOT NULL DEFAULT '', items_ok int(10) unsigned NOT NULL DEFAULT 0, items_failed int(10) unsigned NOT NULL DEFAULT 0, neurons float NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY ts (ts), KEY tool (tool,ts) ) {$charset};"); dbDelta("CREATE TABLE {$items} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, activity_id bigint(20) unsigned NOT NULL, object_type varchar(20) NOT NULL DEFAULT '', object_id bigint(20) unsigned NOT NULL DEFAULT 0, field varchar(64) NOT NULL DEFAULT '', old_value text NOT NULL, new_value text NOT NULL, result varchar(12) NOT NULL DEFAULT '', reason varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY activity (activity_id) ) {$charset};"); // Autoloaded: schedule() reads it on every request. update_option(self::VERSION_OPTION, self::DB_VERSION, true); } private static function tablesExist(): bool { global $wpdb; $suppress = $wpdb->suppress_errors(true); $wpdb->get_var($wpdb->prepare('SELECT 1 FROM %i LIMIT 1', self::table())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $ok = $wpdb->last_error === ''; $wpdb->suppress_errors($suppress); return $ok; } /** * The newest entry for a tool, or null. * * @return array|null */ public static function newest(string $tool = ''): ?array { $rows = self::entries($tool !== '' ? ['tool' => $tool] : [], 1); return $rows[0] ?? null; } public static function drop(): void { global $wpdb; $wpdb->query($wpdb->prepare('DROP TABLE IF EXISTS %i', self::itemsTable())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange $wpdb->query($wpdb->prepare('DROP TABLE IF EXISTS %i', self::table())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange delete_option(self::VERSION_OPTION); } /** * Marks work the plugin does on its own (a cron batch). Backfill switches to an * administrator while it runs, and a system cron may run it through WP-CLI; either way it * is the plugin's work. */ public static function background(bool $on): void { self::$background = $on; } /** * Who is acting in this request: the plugin itself (WP-Cron, a background batch, or nobody * logged in), WP-CLI, or a person. * * @return array{0: string, 1: int} */ public static function actor(): array { if (self::$background || wp_doing_cron()) { return ['plugin', 0]; } if (defined('WP_CLI') && WP_CLI) { return ['cli', get_current_user_id()]; } $user = get_current_user_id(); return $user > 0 ? ['user', $user] : ['plugin', 0]; } /** * Writes one entry and its changes. Returns the entry id, or 0 when it could not be written. * * @param array{tool: string, action: string, status?: string, message?: string, neurons?: float, items_ok?: int, items_failed?: int, actor?: string, user_id?: int} $entry * @param list $items */ public static function add(array $entry, array $items = []): int { global $wpdb; self::install(); [$actor, $user] = self::actor(); if (isset($entry['actor']) && in_array($entry['actor'], self::ACTORS, true)) { $actor = $entry['actor']; $user = (int) ($entry['user_id'] ?? ($actor === 'plugin' ? 0 : $user)); } $status = (string) ($entry['status'] ?? 'ok'); $row = [ 'ts' => current_time('mysql', true), 'actor' => $actor, 'user_id' => $user, 'tool' => substr(sanitize_key($entry['tool']), 0, 20), 'action' => substr(sanitize_key($entry['action']), 0, 40), 'status' => in_array($status, self::STATUSES, true) ? $status : 'info', 'message' => mb_substr(self::plain((string) ($entry['message'] ?? '')), 0, self::MAX_MESSAGE), 'items_ok' => max(0, (int) ($entry['items_ok'] ?? 0)), 'items_failed' => max(0, (int) ($entry['items_failed'] ?? 0)), 'neurons' => round(max(0.0, (float) ($entry['neurons'] ?? 0)), 2), ]; $ok = $wpdb->insert(self::table(), $row); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ($ok === false && !self::tablesExist()) { // The tables went missing while the option stayed (a manual drop, a partial // migration): set them up again once instead of losing every entry from now on. delete_option(self::VERSION_OPTION); self::install(); $ok = $wpdb->insert(self::table(), $row); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } if ($ok === false) { return 0; } $id = (int) $wpdb->insert_id; foreach ($items as $item) { $wpdb->insert(self::itemsTable(), [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery 'activity_id' => $id, 'object_type' => substr(sanitize_key((string) ($item['object_type'] ?? '')), 0, 20), 'object_id' => max(0, (int) ($item['object_id'] ?? 0)), 'field' => mb_substr((string) ($item['field'] ?? ''), 0, 64), 'old_value' => self::value($item['old'] ?? ''), 'new_value' => self::value($item['new'] ?? ''), 'result' => substr(sanitize_key((string) ($item['result'] ?? '')), 0, 12), 'reason' => mb_substr(self::plain((string) ($item['reason'] ?? '')), 0, 255), ]); } return $id; } /** * Entries matching the filters, newest first. * * @param array $filters see where() * @return list> */ public static function entries(array $filters, int $limit, int $offset = 0): array { global $wpdb; if (!self::installed()) { return []; } [$sql, $args] = self::where($filters); array_push($args, $limit, $offset); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql holds only placeholders built in where(); every value goes through prepare() $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE {$sql} ORDER BY id DESC LIMIT %d OFFSET %d", self::table(), ...$args), ARRAY_A); return array_map([self::class, 'cast'], (array) $rows); } /** * Entries matching the filters with an id below $beforeId, newest first: the export walks * the log this way so a large log is read in pages without OFFSET. * * @param array $filters * @return list> */ public static function entriesBefore(array $filters, int $beforeId, int $limit): array { global $wpdb; if (!self::installed()) { return []; } [$sql, $args] = self::where($filters); array_push($args, $beforeId, $limit); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql holds only placeholders built in where(); every value goes through prepare() $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE {$sql} AND id < %d ORDER BY id DESC LIMIT %d", self::table(), ...$args), ARRAY_A); return array_map([self::class, 'cast'], (array) $rows); } /** * @param array $filters */ public static function count(array $filters): int { global $wpdb; if (!self::installed()) { return 0; } [$sql, $args] = self::where($filters); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql holds only placeholders built in where(); every value goes through prepare() return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE {$sql}", self::table(), ...$args)); } /** * The changes of these entries, grouped by entry id. * * @param list $ids * @return array>> */ public static function items(array $ids): array { global $wpdb; $ids = array_values(array_filter(array_map('intval', $ids))); if ($ids === [] || !self::installed()) { return []; } $placeholders = implode(',', array_fill(0, count($ids), '%d')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders is only "%d,%d,...", one per spread ID $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE activity_id IN ({$placeholders}) ORDER BY id", self::itemsTable(), ...$ids), ARRAY_A); $out = []; foreach ((array) $rows as $r) { $r['activity_id'] = (int) $r['activity_id']; $r['object_id'] = (int) $r['object_id']; $out[$r['activity_id']][] = $r; } return $out; } /** * Tools that appear in the log, for the filter. * * @return list */ public static function tools(): array { global $wpdb; if (!self::installed()) { return []; } return array_map('strval', (array) $wpdb->get_col($wpdb->prepare('SELECT DISTINCT tool FROM %i ORDER BY tool', self::table()))); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } public static function retentionDays(): int { $d = get_option(self::RETENTION_OPTION, self::DEFAULT_RETENTION); $d = is_numeric($d) ? (int) $d : self::DEFAULT_RETENTION; return in_array($d, self::RETENTION_CHOICES, true) ? $d : self::DEFAULT_RETENTION; } public static function setRetentionDays(int $days): void { update_option(self::RETENTION_OPTION, in_array($days, self::RETENTION_CHOICES, true) ? $days : self::DEFAULT_RETENTION, false); } /** * Daily, once the log exists. */ public static function schedule(): void { $next = wp_next_scheduled(self::HOOK); if (self::installed() && !$next) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', self::HOOK); } elseif (!self::installed() && $next) { wp_clear_scheduled_hook(self::HOOK); } } /** * Deletes entries, and their changes, older than the retention period. */ public static function prune(): int { global $wpdb; $days = self::retentionDays(); if ($days === 0 || !self::installed()) { return 0; } $before = gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS * $days); $last = (int) $wpdb->get_var($wpdb->prepare('SELECT MAX(id) FROM %i WHERE ts < %s', self::table(), $before)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ($last === 0) { return 0; } // Entries are written in time order, so everything up to the newest old id is old. // A range of entries at a time (plain DELETE ... WHERE, which SQLite also runs), with a // time limit: whatever is left goes on the next daily run. $deadline = microtime(true) + 10; $deleted = 0; $from = (int) $wpdb->get_var($wpdb->prepare('SELECT MIN(id) FROM %i', self::table())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery while ($from > 0 && $from <= $last && microtime(true) < $deadline) { $upto = min($last, $from + self::PRUNE_CHUNK - 1); $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE activity_id <= %d', self::itemsTable(), $upto)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $deleted += (int) $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE id <= %d', self::table(), $upto)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $from = $upto + 1; } return $deleted; } /** * Deletes every entry and change. Returns how many entries there were. */ public static function clear(): int { global $wpdb; if (!self::installed()) { return 0; } $n = self::count([]); // DELETE, not TRUNCATE: TRUNCATE commits any open transaction in MySQL. $wpdb->query($wpdb->prepare('DELETE FROM %i', self::itemsTable())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query($wpdb->prepare('DELETE FROM %i', self::table())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return $n; } /** * What the log takes on disk, in bytes (data and indexes), or null when the database does * not say (SQLite). */ public static function size(): ?int { global $wpdb; if (!self::installed()) { return 0; } $suppress = $wpdb->suppress_errors(true); $total = null; foreach ([self::table(), self::itemsTable()] as $t) { $row = $wpdb->get_row($wpdb->prepare('SHOW TABLE STATUS LIKE %s', $t), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if (is_array($row) && isset($row['Data_length'])) { $total = (int) $total + (int) $row['Data_length'] + (int) ($row['Index_length'] ?? 0); } } $wpdb->suppress_errors($suppress); return $total; } /** * A deleted user's entries stay, without the user. */ public static function forgetUser(int $userId): void { global $wpdb; if ($userId > 0 && self::installed()) { $wpdb->update(self::table(), ['user_id' => 0], ['user_id' => $userId]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } } /** * Filters: who (user, plugin, cli), tool, failed (bool), since and until (Y-m-d, UTC days). * * @param array $filters * @return array{0: string, 1: list} */ private static function where(array $filters): array { $sql = ['1=1']; $args = []; $who = (string) ($filters['who'] ?? ''); if (in_array($who, self::ACTORS, true)) { $sql[] = 'actor = %s'; $args[] = $who; } $tool = sanitize_key((string) ($filters['tool'] ?? '')); if ($tool !== '') { $sql[] = 'tool = %s'; $args[] = $tool; } if (!empty($filters['failed'])) { $sql[] = "(status = 'failed' OR items_failed > 0)"; } // Days are the site's days, as the list shows them; the log stores UTC. foreach (['since' => '>=', 'until' => '<'] as $key => $op) { $day = self::day((string) ($filters[$key] ?? '')); if ($day !== '') { $start = new \DateTimeImmutable($day . ' 00:00:00', wp_timezone()); if ($key === 'until') { $start = $start->modify('+1 day'); } $sql[] = "ts {$op} %s"; $args[] = $start->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); } } return [implode(' AND ', $sql), $args]; } /** * A Y-m-d date, or '' when it is not one. */ public static function day(string $value): string { $d = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, new \DateTimeZone('UTC')); return $d !== false && $d->format('Y-m-d') === $value ? $value : ''; } /** * @param mixed $value */ private static function value($value): string { if (is_bool($value)) { $value = $value ? '1' : '0'; } elseif (is_array($value) || is_object($value)) { $value = (string) wp_json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } return mb_substr((string) $value, 0, self::MAX_VALUE); } private static function plain(string $text): string { return trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($text))); } /** * @param array $row * @return array */ private static function cast(array $row): array { foreach (['id', 'user_id', 'items_ok', 'items_failed'] as $k) { $row[$k] = (int) ($row[$k] ?? 0); } $row['neurons'] = (float) ($row['neurons'] ?? 0); return $row; } }