prefix . 'maintained_log'; } /** * Entry types with their labels. * * @return array */ public static function types() { return array( self::TYPE_UPDATE => __( 'Update', 'maintained-log' ), self::TYPE_CHECK => __( 'Check', 'maintained-log' ), self::TYPE_PERFORMANCE => __( 'Performance', 'maintained-log' ), self::TYPE_MANUAL => __( 'Task', 'maintained-log' ), self::TYPE_REPORT => __( 'Report', 'maintained-log' ), self::TYPE_ALERT => __( 'Alert', 'maintained-log' ), self::TYPE_SYSTEM => __( 'System', 'maintained-log' ), ); } /** * Categories offered for manual entries. * * @return array */ public static function categories() { /** * Filters the categories available for manual log entries. * * @param array $categories Slug => label. */ return apply_filters( 'maintained_log_categories', array( 'test' => __( 'Testing', 'maintained-log' ), 'security' => __( 'Security', 'maintained-log' ), 'performance' => __( 'Performance', 'maintained-log' ), 'backup' => __( 'Backup', 'maintained-log' ), 'content' => __( 'Content', 'maintained-log' ), 'incident' => __( 'Incident', 'maintained-log' ), 'other' => __( 'Other', 'maintained-log' ), ) ); } /** * Appends an entry. * * @param array $entry { * @type string $type One of the TYPE_* constants. * @type string $title Human readable summary. * @type string $category Optional category slug. * @type string $status One of the STATUS_* constants. * @type array $details Structured data, stored as JSON. * @type string $source One of the SOURCE_* constants. * @type int $minutes Time spent, for manual entries. * @type int $amends_id Entry this one corrects. * @type string $created_at UTC 'Y-m-d H:i:s'. Defaults to now. * @type int $user_id Defaults to the current user. * } * @return int|false New entry ID, or false on failure. */ public static function add( array $entry ) { global $wpdb; $row = array( 'created_at' => isset( $entry['created_at'] ) ? $entry['created_at'] : current_time( 'mysql', true ), 'type' => isset( $entry['type'] ) ? sanitize_key( $entry['type'] ) : self::TYPE_SYSTEM, 'category' => isset( $entry['category'] ) ? sanitize_key( $entry['category'] ) : '', 'status' => isset( $entry['status'] ) ? sanitize_key( $entry['status'] ) : self::STATUS_INFO, 'title' => isset( $entry['title'] ) ? wp_strip_all_tags( $entry['title'] ) : '', 'details' => isset( $entry['details'] ) ? wp_json_encode( $entry['details'] ) : '', 'user_id' => isset( $entry['user_id'] ) ? absint( $entry['user_id'] ) : get_current_user_id(), 'source' => isset( $entry['source'] ) ? sanitize_key( $entry['source'] ) : self::SOURCE_SYSTEM, 'minutes' => isset( $entry['minutes'] ) ? absint( $entry['minutes'] ) : 0, 'amends_id' => isset( $entry['amends_id'] ) ? absint( $entry['amends_id'] ) : 0, ); $row['hash'] = self::hash_row( $row, self::last_hash() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table. $inserted = $wpdb->insert( self::table(), $row, array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s' ) ); if ( ! $inserted ) { return false; } $id = (int) $wpdb->insert_id; /** * Fires after an entry is added to the log. * * @param int $id Entry ID. * @param array $row Stored row. */ do_action( 'maintained_log_entry_added', $id, $row ); return $id; } /** * Fetches one entry. * * @param int $id Entry ID. * @return object|null */ public static function get( $id ) { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id ) ); return $row ? self::hydrate( $row ) : null; } /** * Queries entries, newest first. * * @param array $args { * @type string $type Filter by type. * @type string[] $types Filter by several types. * @type string $category Filter by category. * @type string $from UTC lower bound, inclusive. * @type string $to UTC upper bound, exclusive. * @type int $per_page Default 50. 0 for no limit. * @type int $page 1-based page. * @type string $order 'DESC' (default) or 'ASC'. * } * @return object[] */ public static function query( array $args = array() ) { global $wpdb; list( $any, $t, $category, $from, $to ) = self::filter_params( $args ); $table = self::table(); $per_page = isset( $args['per_page'] ) ? absint( $args['per_page'] ) : 50; $page = isset( $args['page'] ) ? max( 1, absint( $args['page'] ) ) : 1; $limit = $per_page > 0 ? $per_page : PHP_INT_MAX; $offset = ( $page - 1 ) * $per_page; if ( isset( $args['order'] ) && 'ASC' === strtoupper( $args['order'] ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %d = 0 OR type IN ( %s, %s, %s, %s, %s, %s, %s ) ) AND ( %s = '' OR category = %s ) AND ( %s = '' OR created_at >= %s ) AND ( %s = '' OR created_at < %s ) ORDER BY created_at ASC, id ASC LIMIT %d OFFSET %d", $table, $any, $t[0], $t[1], $t[2], $t[3], $t[4], $t[5], $t[6], $category, $category, $from, $from, $to, $to, $limit, $offset ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %d = 0 OR type IN ( %s, %s, %s, %s, %s, %s, %s ) ) AND ( %s = '' OR category = %s ) AND ( %s = '' OR created_at >= %s ) AND ( %s = '' OR created_at < %s ) ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", $table, $any, $t[0], $t[1], $t[2], $t[3], $t[4], $t[5], $t[6], $category, $category, $from, $from, $to, $to, $limit, $offset ) ); } return array_map( array( __CLASS__, 'hydrate' ), $rows ? $rows : array() ); } /** * Counts entries matching the same filters as query(). * * @param array $args See query(). * @return int */ public static function count( array $args = array() ) { global $wpdb; list( $any, $t, $category, $from, $to ) = self::filter_params( $args ); $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE ( %d = 0 OR type IN ( %s, %s, %s, %s, %s, %s, %s ) ) AND ( %s = '' OR category = %s ) AND ( %s = '' OR created_at >= %s ) AND ( %s = '' OR created_at < %s )", $table, $any, $t[0], $t[1], $t[2], $t[3], $t[4], $t[5], $t[6], $category, $category, $from, $from, $to, $to ) ); } /** * Recomputes the hash chain. * * @return array{ok:bool,checked:int,broken_at:int} */ public static function verify_chain() { global $wpdb; $table = self::table(); $prev = ''; $count = 0; $last = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id > %d ORDER BY id ASC LIMIT 500', $table, $last ), ARRAY_A ); foreach ( $rows as $row ) { $stored = $row['hash']; $id = (int) $row['id']; unset( $row['id'], $row['hash'] ); if ( ! hash_equals( self::hash_row( $row, $prev ), $stored ) ) { return array( 'ok' => false, 'checked' => $count, 'broken_at' => $id, ); } $prev = $stored; $last = $id; ++$count; } } while ( count( $rows ) === 500 ); return array( 'ok' => true, 'checked' => $count, 'broken_at' => 0, ); } /** * Values for the filters shared by query() and count(). Each filter is off * when its value is empty, so the SQL never changes. * * @param array $args See query(). * @return array{0:int,1:string[],2:string,3:string,4:string} Whether types are filtered, the * seven type slots, category, from and to. */ private static function filter_params( array $args ) { $types = array(); if ( ! empty( $args['type'] ) ) { $types[] = sanitize_key( $args['type'] ); } if ( ! empty( $args['types'] ) && is_array( $args['types'] ) ) { $types = array_merge( $types, array_map( 'sanitize_key', $args['types'] ) ); } $filter_types = $types ? 1 : 0; // Only known types can match; the seven slots cover all of them. $types = array_slice( array_values( array_intersect( array_keys( self::types() ), $types ) ), 0, 7 ); $types = array_pad( $types, 7, '' ); $category = ! empty( $args['category'] ) ? sanitize_key( $args['category'] ) : ''; $from = ! empty( $args['from'] ) ? (string) $args['from'] : ''; $to = ! empty( $args['to'] ) ? (string) $args['to'] : ''; return array( $filter_types, $types, $category, $from, $to ); } /** * Hash of the newest entry. * * @return string */ private static function last_hash() { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. return (string) $wpdb->get_var( $wpdb->prepare( 'SELECT hash FROM %i ORDER BY id DESC LIMIT 1', $table ) ); } /** * Hashes a row together with the previous hash. * * @param array $row Row without id and hash. * @param string $prev Previous hash. * @return string */ private static function hash_row( array $row, $prev ) { $fields = array( 'created_at', 'type', 'category', 'status', 'title', 'details', 'user_id', 'source', 'minutes', 'amends_id' ); $parts = array( $prev ); foreach ( $fields as $field ) { $parts[] = isset( $row[ $field ] ) ? (string) $row[ $field ] : ''; } return hash( 'sha256', implode( "\x1f", $parts ) ); } /** * Decodes details and casts numeric columns. * * @param object $row Raw row. * @return object */ private static function hydrate( $row ) { $row->id = (int) $row->id; $row->user_id = (int) $row->user_id; $row->minutes = (int) $row->minutes; $row->amends_id = (int) $row->amends_id; $decoded = $row->details ? json_decode( $row->details, true ) : array(); $row->details = is_array( $decoded ) ? $decoded : array(); return $row; } }