prefix . 'salsox_limits'; } /** * Installs the table if this site does not have the current version. */ public static function maybe_install() { if ( self::DB_VERSION !== get_option( self::DB_VERSION_OPTION ) ) { self::install(); } } /** * Creates the table, or brings it up to date. */ public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); // dbDelta() needs exactly this layout: two spaces after PRIMARY KEY, one field per line. dbDelta( "CREATE TABLE {$table} ( counter_key varchar(64) NOT NULL, hits int(10) unsigned NOT NULL DEFAULT 0, expires_at bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (counter_key), KEY expires_at (expires_at) ) {$charset};" ); update_option( self::DB_VERSION_OPTION, self::DB_VERSION ); } /** * Raises a counter by one and returns its new value. * * @param string $key Counter name, at most 64 characters. * @param int $ttl Seconds the window lasts from the counter's first hit. * @return int|false The value after this hit, or false when it could not be stored. */ public static function hit( $key, $ttl ) { if ( wp_using_ext_object_cache() ) { // add() does nothing if the counter exists, so only the first hit sets the expiry. wp_cache_add( $key, 0, self::GROUP, $ttl ); $hits = wp_cache_incr( $key, 1, self::GROUP ); if ( false === $hits ) { // Evicted between the two calls: start it again at one. $hits = wp_cache_add( $key, 1, self::GROUP, $ttl ) ? 1 : wp_cache_incr( $key, 1, self::GROUP ); } return false === $hits ? false : (int) $hits; } global $wpdb; $table = self::table(); $now = time(); /* * One statement, so it is atomic: it inserts the counter at 1, or raises * it, or, when its window has ended, starts a new window at 1. hits is * assigned before expires_at, so both IF()s read the old expiry. %i * (WordPress 6.2+) quotes the table name as an identifier. */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- An atomic increment on the plugin's own table; caching it would defeat its purpose. $done = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (counter_key, hits, expires_at) VALUES (%s, 1, %d) ON DUPLICATE KEY UPDATE hits = IF(expires_at <= %d, 1, hits + 1), expires_at = IF(expires_at <= %d, %d, expires_at)', $table, $key, $now + $ttl, $now, $now, $now + $ttl ) ); if ( false === $done ) { return false; } /* * Read back after the increment. Another request may have raised it in * between, so this can be higher than this hit alone made it, never * lower: a burst can be refused one request early, never let through. */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Must read the stored value, not a cached copy. $hits = $wpdb->get_var( $wpdb->prepare( 'SELECT hits FROM %i WHERE counter_key = %s', $table, $key ) ); return null === $hits ? false : (int) $hits; } /** * Takes one back from a counter, for a request that was refused or could * not be sent, so the limits count documents actually sent. * * @param string $key Counter name. */ public static function release( $key ) { if ( wp_using_ext_object_cache() ) { wp_cache_decr( $key, 1, self::GROUP ); return; } global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- An atomic decrement on the plugin's own table. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET hits = hits - 1 WHERE counter_key = %s AND hits > 0', $table, $key ) ); } /** * Deletes counters whose window has ended. The cache expires its own. */ public static function purge_expired() { if ( wp_using_ext_object_cache() ) { return; } global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Housekeeping on the plugin's own table. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at <= %d', $table, time() ) ); } }