log( $uri, is_string( $referrer ) ? $referrer : '' ); } /** * Record a 404 hit. * * @param string $uri Request URI. * @param string $referrer Referrer URL. */ public function log( string $uri, string $referrer = '' ): bool { $path = Path::normalize( $uri, false ); if ( null === $path || strlen( $path ) > 2048 || $this->rate_limited() ) { return false; } $referrer = '' !== $referrer ? substr( esc_url_raw( $referrer ), 0, 2048 ) : null; global $wpdb; $table = Schema::table( 'log_404' ); $now = Clock::mysql(); // One statement. New paths are only inserted while the table holds fewer than MAX_ROWS rows, so the // log cannot grow without bound even without an object cache; known paths always count the hit. $hash = sha1( $path ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Single upsert per 404 request. $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (path, path_hash, referrer, hits, first_seen, last_seen) SELECT %s, %s, %s, 1, %s, %s FROM DUAL WHERE ( SELECT COUNT(*) FROM %i AS c ) < %d OR EXISTS ( SELECT 1 FROM %i AS e WHERE e.path_hash = %s ) ON DUPLICATE KEY UPDATE hits = hits + 1, last_seen = VALUES(last_seen), referrer = COALESCE(VALUES(referrer), referrer)', $table, $path, $hash, $referrer, $now, $now, $table, self::MAX_ROWS, $table, $hash ) ); return true; } /** * Per-minute cap, only with a persistent object cache (without one, a counter would cost queries). */ private function rate_limited(): bool { if ( ! wp_using_ext_object_cache() ) { return false; } $key = 'unbreak_404_' . gmdate( 'YmdHi', Clock::now() ); wp_cache_add( $key, 0, 'unbreak', 120 ); return (int) wp_cache_incr( $key, 1, 'unbreak' ) > self::PER_MINUTE; } /** * Keep at most MAX_ROWS rows (least-hit and oldest go first) and drop rows older than the retention period. */ public static function trim(): void { global $wpdb; $table = Schema::table( 'log_404' ); $days = (int) Settings::get( 'retention_days' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Daily maintenance on a plugin table. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE last_seen < %s', $table, Clock::to_mysql( Clock::now() - $days * DAY_IN_SECONDS ) ) ); $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); if ( $total > self::MAX_ROWS ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i ORDER BY hits ASC, last_seen ASC LIMIT %d', $table, $total - self::MAX_ROWS ) ); } // phpcs:enable } /** * Paginated list, most hits first. * * @param int $page Page. * @param int $per_page Rows per page. * @param string $search Search. * @return array{items:array>,total:int} */ public static function paginate( int $page = 1, int $per_page = 50, string $search = '' ): array { global $wpdb; $table = Schema::table( 'log_404' ); $like = '%' . $wpdb->esc_like( $search ) . '%'; $offset = max( 0, ( $page - 1 ) * $per_page ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE ( %s = '' OR path LIKE %s )", $table, $search, $like ) ); $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, path, referrer, hits, first_seen, last_seen FROM %i WHERE ( %s = '' OR path LIKE %s ) ORDER BY hits DESC, last_seen DESC LIMIT %d OFFSET %d", $table, $search, $like, $per_page, $offset ), ARRAY_A ); // phpcs:enable $items = array(); foreach ( $rows as $row ) { $items[] = array( 'id' => (int) $row['id'], 'path' => (string) $row['path'], 'referrer' => $row['referrer'], 'hits' => (int) $row['hits'], 'first_seen' => $row['first_seen'], 'last_seen' => $row['last_seen'], ); } return array( 'items' => $items, 'total' => $total, ); } /** * Delete one row, or all rows when $id is 0. * * @param int $id Row ID. */ public static function delete( int $id = 0 ): void { global $wpdb; $table = Schema::table( 'log_404' ); if ( 0 === $id ) { $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- Clear the plugin's own log. return; } $wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. } }