$row The row as EventLogger::build_row() made it. * @param string $timestamp UTC `Y-m-d H:i:s`, set here so the digest covers it. * @return int|null The new row's id, or null when nothing was stored. */ public static function insert( array $row, string $timestamp ): ?int { global $wpdb; $key = Key::get(); $table = Database::get_table_name(); $norm = Seal::normalise( array( 'timestamp' => $timestamp ) + $row, self::four_bytes() ); $content = Seal::content( $key, $norm ); $suppress = $wpdb->suppress_errors( true ); try { for ( $attempt = 0; $attempt < self::ATTEMPTS; $attempt++ ) { $prev = self::tip( $key, $attempt > 0 ); $hash = Seal::link( $key, $prev, $content ); $ok = $wpdb->insert( $table, $norm + array( 'chain_prev' => $prev, 'chain_content' => $content, 'chain_hash' => $hash, ), self::formats( true ) ); if ( false !== $ok ) { // Read before the head is written: writing an option that // does not exist yet is an INSERT, which replaces insert_id. $id = (int) $wpdb->insert_id; Ledger::write_head( $key, $id, $hash ); return $id; } if ( false === stripos( (string) $wpdb->last_error, 'duplicate' ) ) { break; } } // Could not seal: keep the event, say so at verification. $ok = $wpdb->insert( $table, $norm, self::formats( false ) ); } finally { $wpdb->suppress_errors( $suppress ); } return false === $ok ? null : (int) $wpdb->insert_id; } /** * What the next row links to: the recorded head (no query against the * log table on the common path), else the newest sealed row of the * current chain, else genesis for the chain's epoch. * * The head can lag the table by a request that inserted but has not yet * written it; a row linked to a lagging head is refused by the UNIQUE * key, and the retry passes `$from_table` to read the table instead. * * @param string $key Signing key. * @param bool $from_table Skip the head and read the table. */ public static function tip( string $key, bool $from_table = true ): string { global $wpdb; $table = Database::get_table_name(); $floor = Ledger::cuts( $key ); $floor = Ledger::OK === $floor['status'] ? $floor['floor'] : 0; if ( ! $from_table ) { $head = Ledger::head( $key ); if ( Ledger::OK === $head['status'] && $head['id'] > $floor ) { return $head['hash']; } } // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table; $table is prefix-composed, never request data; a read of the newest row cannot be cached. $hash = $wpdb->get_var( $wpdb->prepare( "SELECT chain_hash FROM $table WHERE chain_hash IS NOT NULL AND id > %d ORDER BY id DESC LIMIT 1", $floor ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( is_string( $hash ) && Seal::is_digest( $hash ) ) { return $hash; } // An emptied log (retention removed every row): continue from the // recorded head so the chain is not restarted by a purge. $head = Ledger::head( $key ); if ( Ledger::OK === $head['status'] && $head['id'] > $floor ) { return $head['hash']; } return Seal::genesis( $key, $floor ); } /** * `$wpdb->insert()` formats, in the order normalise() returns columns and * then the three chain columns. * * @param bool $sealed Whether the chain columns are written. * @return array */ private static function formats( bool $sealed ): array { $formats = array( '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ); return $sealed ? array_merge( $formats, array( '%s', '%s', '%s' ) ) : $formats; } /** Whether the log table's charset stores four-byte characters. */ private static function four_bytes(): bool { global $wpdb; return 0 === strpos( strtolower( (string) $wpdb->charset ), 'utf8mb4' ); } }