prefix . 'threedoor_log'; } /** * Register hooks. * * @return void */ public static function init() { add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'record' ), 99, 3 ); add_action( 'threedoor_cleanup', array( __CLASS__, 'cleanup' ) ); add_action( 'plugins_loaded', array( __CLASS__, 'maybe_install' ) ); } /** * Create or update the table. * * @return void */ public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, day date NOT NULL, route varchar(191) NOT NULL, method varchar(10) NOT NULL, who varchar(20) NOT NULL, denied tinyint(1) NOT NULL DEFAULT 0, status smallint(5) unsigned NOT NULL DEFAULT 0, hits int(10) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY entry (day,route,method,who,denied) ) $charset;" ); update_option( 'threedoor_db_version', self::DB_VERSION, false ); add_option( 'threedoor_learn_since', time(), '', false ); if ( ! wp_next_scheduled( 'threedoor_cleanup' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'threedoor_cleanup' ); } } /** * Install after an update that changed the table. * * @return void */ public static function maybe_install() { if ( self::DB_VERSION !== get_option( 'threedoor_db_version' ) ) { self::install(); } } /** * Normalize a route: numbers and long hex values become *. * * @param string $route Route. * @return string */ public static function normalize( $route ) { $parts = explode( '/', (string) $route ); foreach ( $parts as $i => $part ) { if ( preg_match( '/^\d+$/', $part ) || preg_match( '/^[0-9a-f-]{16,}$/i', $part ) ) { $parts[ $i ] = '*'; } } $route = implode( '/', $parts ); return '' === $route ? '/' : substr( $route, 0, 191 ); } /** * Filter: count the request. Returns the response unchanged. * * @param mixed $response Response. * @param array $handler Handler. * @param WP_REST_Request $request Request. * @return mixed */ public static function record( $response, $handler, $request ) { $settings = ThreeDoor_Settings::get(); if ( empty( $settings['learn'] ) ) { return $response; } global $wpdb; $described = ThreeDoor_Guard::describe( $request ); $who = 'none' === $described['auth'] ? 'guest' : $described['auth']; $status = 200; $denied = 0; if ( is_wp_error( $response ) ) { $data = $response->get_error_data(); $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500; $denied = 0 === strpos( (string) $response->get_error_code(), ThreeDoor_Guard::ERROR_PREFIX ) ? 1 : 0; } elseif ( $response instanceof WP_HTTP_Response ) { $status = (int) $response->get_status(); } $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- counter write, nothing to cache. $wpdb->query( $wpdb->prepare( "INSERT INTO %i (day, route, method, who, denied, status, hits) VALUES (%s, %s, %s, %s, %d, %d, 1) ON DUPLICATE KEY UPDATE hits = hits + 1, status = VALUES(status)", $table, current_time( 'Y-m-d' ), self::normalize( $described['route'] ), substr( strtoupper( (string) $described['method'] ), 0, 10 ), $who, $denied, $status ) ); return $response; } /** * Counters of the last days, grouped. * * @param int $days Days. * @return array rows with route, method, who, denied (bool), status, hits, last. */ public static function summary( $days = self::KEEP_DAYS ) { global $wpdb; $table = self::table(); $since = gmdate( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) ) - (int) $days * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin report. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT route, method, who, denied, MAX(status) AS status, SUM(hits) AS hits, MAX(day) AS last FROM %i WHERE day >= %s GROUP BY route, method, who, denied ORDER BY hits DESC", $table, $since ), ARRAY_A ); foreach ( (array) $rows as $i => $row ) { $rows[ $i ]['denied'] = (bool) $row['denied']; $rows[ $i ]['hits'] = (int) $row['hits']; $rows[ $i ]['status'] = (int) $row['status']; } return (array) $rows; } /** * Suggest a guest policy from what guests really used. * * Core routes are proposed per resource (/wp/v2/posts), plugin routes per namespace. * * @return array default, allow, uncertain. */ public static function suggest() { $namespaces = rest_get_server()->get_namespaces(); usort( $namespaces, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); $allow = array(); foreach ( self::summary() as $row ) { if ( 'guest' !== $row['who'] || $row['denied'] ) { continue; } $route = $row['route']; if ( 0 === strpos( $route, '/wp/v2/' ) ) { $parts = explode( '/', trim( $route, '/' ) ); $allow[] = '/wp/v2/' . $parts[2]; continue; } foreach ( $namespaces as $ns ) { if ( 0 === strpos( $route, '/' . $ns . '/' ) || '/' . $ns === $route ) { $allow[] = $ns; continue 2; } } $allow[] = $route; } $allow = array_values( array_unique( $allow ) ); sort( $allow ); return array( 'default' => 'deny', 'allow' => $allow, // With a REST cache in front, "never seen" does not mean "never used". 'uncertain' => ThreeDoor_Cache::state()['rest_cached'], ); } /** * Delete all counters. * * @return void */ public static function reset() { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- deleting our own counters. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $table ) ); } /** * Cron: delete counters older than the retention period. * * @return void */ public static function cleanup() { global $wpdb; $table = self::table(); $until = gmdate( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) ) - self::KEEP_DAYS * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- retention of our own counters. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE day < %s', $table, $until ) ); } }