*/ private static $instances = []; /** * Ensures only one instance of each concrete controller is loaded. * * @return static */ public static function get() { $class = static::class; if ( !isset( self::$instances[$class] ) ) { self::$instances[$class] = new static(); } return self::$instances[$class]; } /** * Private constructor to prevent direct instantiation. */ protected function __construct() {} /** * Abstract method to get the database table name for the controller. * * @return string */ abstract protected function get_table_name(): string; /** * Columns that the `s` search parameter is matched against. * * Each table has a different schema, so a shared column list produces * "Unknown column" SQL errors. Subclasses override this with the columns * that actually exist on their table. * * @return string[] */ protected function get_searchable_columns(): array { return [ 'ip_address', 'user_agent' ]; } /** * Column used by the `range` time filter, if the table supports one. * * @return string Empty string disables range filtering. */ protected function get_time_column(): string { return ''; } /** * Translate a `range` parameter into a site-local cut-off datetime. * * @param string $range One of 24h, 7d, 30d. Anything else means no limit. * @return string Empty string when unbounded. */ protected function range_to_datetime( string $range ): string { $windows = [ '24h' => DAY_IN_SECONDS, '7d' => 7 * DAY_IN_SECONDS, '30d' => 30 * DAY_IN_SECONDS, ]; if ( !isset( $windows[$range] ) ) { return ''; } // Rows are written with current_time( 'mysql' ), so the cut-off has to // be site-local too. return wp_date( 'Y-m-d H:i:s', time() - $windows[$range] ); } /** * Generic method to return a paginated list of records from a specified table. * * @param WP_REST_Request $request * @param string $table_name The name of the database table to query. * @return WP_REST_Response */ protected function get_records( WP_REST_Request $request, string $table_name ) { global $wpdb; // Sanitize and validate parameters $page = absint( $request->get_param( 'page' ) ); $limit = absint( $request->get_param( 'limit' ) ); $search = sanitize_text_field( (string) $request->get_param( 's' ) ); $page = $page > 0 ? $page : 1; // Cap the page size so a crafted request cannot ask for the whole table. $limit = $limit > 0 ? min( $limit, 100 ) : 10; $offset = ( $page - 1 ) * $limit; $full_table_name = get_tpsa_db_table_name( $table_name ); // Verify table exists (cached). if ( !$this->table_exists( $full_table_name ) ) { return new WP_REST_Response( [ 'error' => true, 'message' => __( 'Table does not exist.', 'admin-safety-guard' ), ], 500 ); } // Build the WHERE clause as a placeholder template plus a value list, so the // query is only ever passed through prepare() once. Preparing an // already-prepared fragment a second time makes wpdb re-scan the escaped // values for printf placeholders, which breaks on any search term // containing a '%'. $clauses = []; $where_values = []; if ( '' !== $search ) { $like_search = '%' . $wpdb->esc_like( $search ) . '%'; $conditions = []; foreach ( $this->get_searchable_columns() as $column ) { // Column names come from a hard-coded per-controller allowlist. $conditions[] = '`' . str_replace( '`', '', $column ) . '` LIKE %s'; $where_values[] = $like_search; } if ( $conditions ) { $clauses[] = '(' . implode( ' OR ', $conditions ) . ')'; } } // Optional time window, e.g. only the last 24 hours. $time_column = $this->get_time_column(); $since = $this->range_to_datetime( sanitize_key( (string) $request->get_param( 'range' ) ) ); if ( '' !== $time_column && '' !== $since ) { $clauses[] = '`' . str_replace( '`', '', $time_column ) . '` >= %s'; $where_values[] = $since; } $where_sql = $clauses ? 'WHERE ' . implode( ' AND ', $clauses ) : ''; // Get total number of filtered records $count_sql = "SELECT COUNT(*) FROM {$full_table_name} {$where_sql}"; if ( $where_values ) { $count_sql = $wpdb->prepare( $count_sql, $where_values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } $total_items = (int) $wpdb->get_var( $count_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared // Fetch paginated results $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$full_table_name} {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge( $where_values, [ $limit, $offset ] ) ), ARRAY_A ); if ( empty( $results ) && '' !== $search ) { return $this->build_response( [], $total_items, $page, $limit, __( 'No records found for the given search.', 'admin-safety-guard' ) ); } elseif ( empty( $results ) ) { return $this->build_response( [], $total_items, $page, $limit, __( 'No records found for the given page.', 'admin-safety-guard' ) ); } return $this->build_response( $results, $total_items, $page, $limit ); } /** * Retrieves the total number of records in the specified table. * * If no table name is provided, the controller's default table name is used. * * @param string $table_name Optional. The name of the database table to query. Defaults to the controller's default table name. * @return int The total number of records in the specified table. */ protected function get_record_count( string $table_name = '' ): int { global $wpdb; // If no table passed, use controller default table $table_name = $table_name !== '' ? $table_name : $this->get_table_name(); $full_table_name = get_tpsa_db_table_name( $table_name ); // Check table exists (cached). if ( !$this->table_exists( $full_table_name ) ) { return 0; } return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$full_table_name}" ); } /** * Whether a fully-qualified table exists, cached per request and for a short * window via a transient to avoid a SHOW TABLES query on every API call. * * @param string $full_table_name Fully-prefixed table name. * @return bool */ protected function table_exists( string $full_table_name ): bool { global $wpdb; static $checked = []; if ( isset( $checked[$full_table_name] ) ) { return $checked[$full_table_name]; } $cache_key = 'tpsa_table_exists_' . md5( $full_table_name ); $cached = get_transient( $cache_key ); if ( '1' === $cached ) { return $checked[$full_table_name] = true; } $exists = ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $full_table_name ) ) === $full_table_name ); // Only cache positive results; a missing table should be re-checked // (e.g. right after activation creates it). if ( $exists ) { set_transient( $cache_key, '1', HOUR_IN_SECONDS ); } return $checked[$full_table_name] = $exists; } /** * Default permission check callback. Can be overridden by subclasses. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function authorize_request( WP_REST_Request $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'rest_forbidden', __( 'You do not have permission to access this resource.', 'admin-safety-guard' ), ['status' => 403] ); } return true; } /** * Build a standard REST response. * * @param array $data * @param int $total * @param int $page * @param int $limit * @param string|null $notice Optional notice message. * * @return WP_REST_Response */ protected function build_response( $data, $total, $page, $limit, $notice = null ) { $response = [ 'data' => $data, 'total' => $total, 'page' => $page, 'limit' => $limit, ]; if ( $notice ) { $response['notice'] = $notice; } return new WP_REST_Response( $response, 200 ); } }