get_charset_collate(); $sql = "CREATE TABLE $table ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, unit varchar(191) NOT NULL, path_hash char(32) NOT NULL, dir_hash char(32) NOT NULL, path text NOT NULL, source varchar(10) NOT NULL, expected varchar(300) DEFAULT NULL, status varchar(12) NOT NULL DEFAULT 'ok', severity tinyint(3) unsigned NOT NULL DEFAULT 0, actual_md5 char(32) DEFAULT NULL, actual_size bigint(20) unsigned DEFAULT NULL, actual_mtime bigint(20) unsigned DEFAULT NULL, seen_scan bigint(20) unsigned NOT NULL DEFAULT 0, reported_scan bigint(20) unsigned NOT NULL DEFAULT 0, detected_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY path_hash (path_hash), KEY dir_hash (dir_hash), KEY unit_seen (unit,seen_scan), KEY status (status) ) $charset;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** Drop the table. Uninstall only. */ public static function drop_table(): void { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- the plugin's own table, dropped on uninstall. $wpdb->query( "DROP TABLE IF EXISTS $table" ); delete_option( self::SCHEMA_OPTION ); } public static function table(): string { global $wpdb; return $wpdb->prefix . self::TABLE_NAME; } /** * @param string $unit Unit key. * @param string $path Path within the unit's root. */ public static function path_hash( string $unit, string $path ): string { return md5( $unit . '|' . $path ); } /** * The directory key of a path: its unit and parent directory ('' at the * unit's root). * * @param string $unit Unit key. * @param string $path Path within the unit's root. */ public static function dir_hash( string $unit, string $path ): string { $dir = dirname( $path ); return md5( $unit . '|' . ( '.' === $dir ? '' : $dir ) ); } /** * The rows of one directory, keyed by path. * * @param string $unit Unit key. * @param string $dir Directory within the unit's root ('' for the root). * @return array> */ public static function rows_in( string $unit, string $dir ): array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; values prepared. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table WHERE dir_hash = %s", md5( $unit . '|' . $dir ) ), ARRAY_A ); $by_path = array(); foreach ( (array) $rows as $row ) { if ( is_array( $row ) && $unit === $row['unit'] ) { $by_path[ (string) $row['path'] ] = $row; } } return $by_path; } /** * @param int $id Row id. * @return array|null */ public static function row( int $id ): ?array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; values prepared. $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $id ), ARRAY_A ); return is_array( $row ) ? $row : null; } /** * Insert a row, if the ceilings allow. * * @param array $row Column values (unit, path, source, expected, status, severity, actual_*, seen_scan, reported_scan, detected_at). * @return bool Whether it was written. */ public static function insert( array $row ): bool { global $wpdb; $finding = Comparison::OK !== ( $row['status'] ?? Comparison::OK ); if ( self::count_all() >= self::$row_limit || ( $finding && self::count_findings() >= self::$finding_limit ) ) { return false; } $unit = (string) $row['unit']; $path = (string) $row['path']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $written = $wpdb->insert( self::table(), array( 'unit' => $unit, 'path_hash' => self::path_hash( $unit, $path ), 'dir_hash' => self::dir_hash( $unit, $path ), 'path' => $path, 'source' => (string) ( $row['source'] ?? self::SOURCE_NONE ), 'expected' => $row['expected'] ?? null, 'status' => (string) ( $row['status'] ?? Comparison::OK ), 'severity' => (int) ( $row['severity'] ?? 0 ), 'actual_md5' => $row['actual_md5'] ?? null, 'actual_size' => $row['actual_size'] ?? null, 'actual_mtime' => $row['actual_mtime'] ?? null, 'seen_scan' => (int) ( $row['seen_scan'] ?? 0 ), 'reported_scan' => (int) ( $row['reported_scan'] ?? 0 ), 'detected_at' => $row['detected_at'] ?? null, ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%d', '%d', '%s' ) ); // Kept current rather than forgotten: a core baseline is thousands of // inserts in one request, and re-counting before each would be a // COUNT(*) per file. if ( false !== $written && null !== self::$counts ) { ++self::$counts['all']; self::$counts['findings'] += $finding ? 1 : 0; } return false !== $written; } /** * Update named columns of one row. * * @param int $id Row id. * @param array $fields Columns to set. */ public static function update( int $id, array $fields ): void { global $wpdb; $formats = array(); foreach ( $fields as $column => $value ) { $formats[] = is_int( $value ) ? '%d' : '%s'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $wpdb->update( self::table(), $fields, array( 'id' => $id ), $formats, array( '%d' ) ); // The memoised totals are left alone: an update never adds a row, // and a status change moving the findings total by one is corrected // by the refresh() each scan run starts with. The ceilings are // ceilings on growth, not exact quotas. } /** * The units that have rows. * * @return array */ public static function units(): array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; no input. $units = $wpdb->get_col( "SELECT DISTINCT unit FROM $table" ); return array_map( 'strval', (array) $units ); } /** * @param int $id Row id. */ public static function delete( int $id ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $wpdb->delete( self::table(), array( 'id' => $id ), array( '%d' ) ); self::$counts = null; } /** * Forget every row of a unit: its expected hashes and its findings. * * @param string $unit Unit key. * @return int Rows removed. */ public static function forget_unit( string $unit ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $deleted = $wpdb->delete( self::table(), array( 'unit' => $unit ), array( '%s' ) ); self::$counts = null; return is_int( $deleted ) ? $deleted : 0; } /** * Rows of a unit the given scan did not see, a page at a time. * * @param string $unit Unit key. * @param int $scan_id The scan. * @param int $limit Page size. * @return array> */ public static function unseen( string $unit, int $scan_id, int $limit ): array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; values prepared. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table WHERE unit = %s AND seen_scan <> %d ORDER BY id LIMIT %d", $unit, $scan_id, $limit ), ARRAY_A ); return array_values( array_filter( (array) $rows, 'is_array' ) ); } /** * Whether a unit has any row. * * @param string $unit Unit key. */ public static function has_unit( string $unit ): bool { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; values prepared. return null !== $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table WHERE unit = %s LIMIT 1", $unit ) ); } /** * Findings for the screen: most severe first, then newest. * * @param int $limit Page size. * @param int $offset Offset. * @param string $unit Optional unit filter ('' for all). * @return array> */ public static function findings( int $limit, int $offset = 0, string $unit = '' ): array { global $wpdb; $table = self::table(); if ( '' === $unit ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; values prepared. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table WHERE status <> 'ok' ORDER BY severity DESC, id DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A ); } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; values prepared. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table WHERE status <> 'ok' AND unit = %s ORDER BY severity DESC, id DESC LIMIT %d OFFSET %d", $unit, $limit, $offset ), ARRAY_A ); } return array_values( array_filter( (array) $rows, 'is_array' ) ); } /** * Finding counts by severity. * * @return array Severity => count, for HIGH, MEDIUM and LOW. */ public static function severity_counts(): array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; no input. $rows = $wpdb->get_results( "SELECT severity, COUNT(*) AS n FROM $table WHERE status <> 'ok' GROUP BY severity", ARRAY_A ); $counts = array( Comparison::HIGH => 0, Comparison::MEDIUM => 0, Comparison::LOW => 0, ); foreach ( (array) $rows as $row ) { $severity = is_array( $row ) ? (int) $row['severity'] : 0; if ( isset( $counts[ $severity ] ) ) { $counts[ $severity ] = (int) $row['n']; } } return $counts; } /** * Row and finding totals, memoised until the next write. * * @var array{all:int,findings:int}|null */ private static ?array $counts = null; public static function count_all(): int { return self::counts()['all']; } public static function count_findings(): int { return self::counts()['findings']; } /** Forget the memoised totals (another request may have written). */ public static function refresh(): void { self::$counts = null; } /** * @return array{all:int,findings:int} */ private static function counts(): array { global $wpdb; if ( null === self::$counts ) { $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the plugin's own table; no input. $row = $wpdb->get_row( "SELECT COUNT(*) AS a, COALESCE(SUM(status <> 'ok'), 0) AS f FROM $table", ARRAY_A ); self::$counts = array( 'all' => is_array( $row ) ? (int) $row['a'] : 0, 'findings' => is_array( $row ) ? (int) $row['f'] : 0, ); } return self::$counts; } }