*/
const SORTABLE_COLUMN_MAP = array(
'created' => 'created',
'ip' => 'ip',
'path' => 'path',
'referer' => 'referer',
'user_agent' => 'user_agent',
'i' => 'ip',
'p' => 'path',
'r' => 'referer',
'u' => 'user_agent',
);
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
array(
'singular' => 'log',
'plural' => 'logs',
'ajax' => false,
)
);
}
/**
* Add stuff on top or bottom of the Logs Table.
*
* @param string $which top/bottom.
* @return void
*/
public function extra_tablenav( $which ) {
// No additional navigation content needed.
}
/**
* Prepares the list of items for displaying.
*
* The query is assembled in SQL order — WHERE, then ORDER BY, then LIMIT —
* and pagination is applied by the database. Only the rows for the current
* page are ever read into PHP, so the screen stays usable on sites with very
* large log tables.
*
* @since 3.15.4 Pagination moved into SQL; clause order corrected.
*/
public function prepare_items() {
global $wpdb;
$columns = $this->get_columns();
$hidden = array();
$sortable = $this->get_sortable_columns();
$this->_column_headers = array( $columns, $hidden, $sortable );
$helpers = Helpers::singleton();
$table = $wpdb->prefix . $helpers->table_logs;
// WHERE must be built first: appending ORDER BY before it is invalid SQL.
$where = '';
if ( array_key_exists( 's', $_GET ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$search = sanitize_text_field( wp_unslash( $_GET['s'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( '' !== $search ) {
$where = $this->build_search_clause( $search );
}
}
// Count every matching row so pagination reports the real total.
$total_items = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . $table . $where ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
$sql = 'SELECT * FROM ' . $table . $where;
$order_by = '';
$order = '';
if ( array_key_exists( 'orderby', $_GET ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$order_by = sanitize_text_field( wp_unslash( $_GET['orderby'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$order = isset( $_GET['order'] ) ? sanitize_text_field( wp_unslash( $_GET['order'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
$sql .= $this->build_order_clause( $order_by, $order );
$per_page = self::PER_PAGE;
$current_page = max( 1, $this->get_pagenum() );
$this->set_pagination_args(
array(
'total_items' => $total_items,
'per_page' => $per_page,
)
);
$sql .= $wpdb->prepare( ' LIMIT %d OFFSET %d', $per_page, ( $current_page - 1 ) * $per_page );
$sql_data = $wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
$data = array();
foreach ( (array) $sql_data as $row ) {
$data[] = array(
'id' => (int) $row->id,
'ip' => sanitize_text_field( $row->ip ),
'path' => sanitize_text_field( $row->path ),
'referer' => sanitize_text_field( $row->referer ),
'user_agent' => sanitize_text_field( $row->user_agent ),
'created' => sanitize_text_field( $row->created ),
);
}
$this->items = $data;
}
/**
* Appends an ORDER BY clause for a recognised sortable column.
*
* The column name is resolved through SORTABLE_COLUMN_MAP and the direction
* is narrowed to the literal 'ASC' or 'DESC', so neither value can carry
* user input into the query. An unrecognised column is ignored entirely.
*
* @since 3.15.4 Column names are whitelisted; unknown values no longer emit a bare direction.
* @param string $order_by Requested orderby query argument.
* @param string $order Requested sort direction.
* @param string $sql SQL query string built so far.
* @return string Modified SQL query string.
*/
public function manage_sorting( $order_by, $order, $sql ) {
if ( ! isset( self::SORTABLE_COLUMN_MAP[ $order_by ] ) ) {
return $sql;
}
return $sql . $this->build_order_clause( $order_by, $order );
}
/**
* Builds the ORDER BY clause for the row query.
*
* Always returns a clause, and always ends on `id`.
*
* Two reasons the sort must be total rather than just correct. LIMIT/OFFSET
* has no defined row order in SQL unless the ORDER BY distinguishes every
* row, so a sort that ties leaves the database free to return a row on two
* different pages and omit another entirely. `created` has one-second
* resolution and a bot crawl writes dozens of rows inside the same second,
* so ties here are routine rather than exotic. `id` is the primary key and
* breaks every tie.
*
* With no sort requested the rows come back in `id` order, which is the
* order the screen showed before pagination moved into SQL.
*
* @since 3.15.4
* @param string $order_by Requested orderby query argument.
* @param string $order Requested sort direction.
* @return string ORDER BY clause, including the leading space.
*/
private function build_order_clause( $order_by, $order ) {
$direction = ( 'DESC' === strtoupper( (string) $order ) ) ? 'DESC' : 'ASC';
if ( ! isset( self::SORTABLE_COLUMN_MAP[ $order_by ] ) ) {
return ' ORDER BY id ASC';
}
return ' ORDER BY ' . self::SORTABLE_COLUMN_MAP[ $order_by ] . ' ' . $direction . ', id ' . $direction;
}
/**
* Builds the prepared WHERE clause for a search term.
*
* @since 3.15.4
* @param string $search Search string.
* @return string Prepared WHERE clause, including the leading space.
*/
private function build_search_clause( $search ) {
global $wpdb;
$like = '%' . $wpdb->esc_like( $search ) . '%';
return $wpdb->prepare(
' WHERE (ip LIKE %s OR path LIKE %s OR referer LIKE %s OR user_agent LIKE %s OR created LIKE %s)',
$like,
$like,
$like,
$like,
$like
);
}
/**
* Handles search filtering of the logs table.
*
* @param string $search Search string.
* @param string $sql SQL query string.
* @return string Modified SQL query string.
*/
public function manage_search( $search, $sql ) {
return $sql . $this->build_search_clause( $search );
}
/**
* Returns the columns for the logs table.
*
* @return array Column definitions.
*/
public function get_columns() {
$columns = array(
'cb' => "",
'ip' => esc_html__( 'IP', 'custom-404-pro' ),
'path' => esc_html__( 'Path', 'custom-404-pro' ),
'referer' => esc_html__( 'Referer', 'custom-404-pro' ),
'user_agent' => esc_html__( 'User Agent', 'custom-404-pro' ),
'created' => esc_html__( 'Created', 'custom-404-pro' ),
);
return $columns;
}
/**
* Renders the default column value.
*
* @param array $item Row data.
* @param string $column_name Column name.
* @return mixed Column value.
*/
public function column_default( $item, $column_name ) {
switch ( $column_name ) {
case 'cb':
return (int) $item['id'];
case 'ip':
case 'path':
case 'referer':
case 'user_agent':
case 'created':
// WP_List_Table echoes column values verbatim, so escape here.
return esc_html( $item[ $column_name ] );
}
}
/**
* Returns the sortable columns.
*
* @return array Sortable column definitions.
*/
public function get_sortable_columns() {
$sortable_columns = array(
'ip' => 'ip',
'path' => 'path',
'referer' => 'referer',
'user_agent' => 'user_agent',
'created' => array( 'created', true ),
);
return $sortable_columns;
}
/**
* Renders the IP column with row actions.
*
* @param array $item Row data.
* @return string Column HTML.
*/
public function column_ip( $item ) {
$page_slug = isset( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$delete_link = wp_nonce_url(
add_query_arg(
array(
'page' => $page_slug,
'action' => 'c4p-logs--delete',
'path' => (int) $item['id'],
),
admin_url( 'admin.php' )
),
'c4p-logs--delete'
);
$actions = array(
'c4p-logs--delete' => sprintf(
'%2$s',
esc_url( $delete_link ),
esc_html__( 'Delete', 'custom-404-pro' )
),
);
return sprintf(
'%1$s %2$s',
esc_html( $item['ip'] ),
$this->row_actions( $actions )
);
}
/**
* Renders the checkbox column.
*
* @param array $item Row data.
* @return string Column HTML.
*/
public function column_cb( $item ) {
return sprintf(
'',
(int) $item['id'],
/* translators: %s: the 404 path recorded in this log row */
esc_attr( sprintf( __( 'Select log entry for %s', 'custom-404-pro' ), $item['path'] ) )
);
}
/**
* Returns the bulk actions for the logs table.
*
* @return array Bulk action definitions.
*/
public function get_bulk_actions() {
$actions = array(
'c4p-logs--delete' => esc_html__( 'Delete', 'custom-404-pro' ),
'c4p-logs--delete-all' => esc_html__( 'Delete All', 'custom-404-pro' ),
'c4p-logs--export-csv' => esc_html__( 'Export All (.csv)', 'custom-404-pro' ),
);
return $actions;
}
}