esc_html__( 'Entry', '24tt-unrestricted-forms' ), // Singular label 'plural' => esc_html__( 'Entries', '24tt-unrestricted-forms' ), // Plural label 'ajax' => false ) ); } /** * Define the columns that are going to be used in the table. * * @since 1.0.0 * @return array Array of columns to use with the table. */ public function get_columns() { $columns = array( 'cb' => '', // Render a checkbox for bulk actions 'id' => esc_html__( 'Entry ID', '24tt-unrestricted-forms' ), 'form_name' => esc_html__( 'Form Name', '24tt-unrestricted-forms' ), 'ip_address' => esc_html__( 'IP Address', '24tt-unrestricted-forms' ), 'status' => esc_html__( 'Status', '24tt-unrestricted-forms' ), 'created_at' => esc_html__( 'Date Submitted', '24tt-unrestricted-forms' ) ); return $columns; } /** * Define which columns are sortable. * * @since 1.0.0 * @return array Array of sortable columns. */ protected function get_sortable_columns() { $sortable_columns = array( 'id' => array( 'id', false ), 'form_name' => array( 'form_name', false ), 'created_at' => array( 'created_at', true ) // True means it's already sorted by this by default ); return $sortable_columns; } /** * Fallback method for rendering column data. * * @since 1.0.0 * @param array $item A singular item (one full row's worth of data). * @param string $column_name The name/slug of the column to be processed. * @return string Text or HTML to be placed inside the column . */ protected function column_default( $item, $column_name ) { switch ( $column_name ) { case 'ip_address': return esc_html( $item[ $column_name ] ); case 'status': // Capitalize the status nicely (e.g., 'unread' -> 'Unread') return esc_html( ucfirst( $item[ $column_name ] ) ); case 'created_at': // Format the date based on the WordPress general settings return esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $item[ $column_name ] ) ) ); case 'form_name': return ! empty( $item['form_name'] ) ? esc_html( $item['form_name'] ) : esc_html__( 'Unknown Form', '24tt-unrestricted-forms' ); default: // FIX: Removed print_r() as it is flagged as debug code by PCP tool return ''; } } /** * Render the Entry ID column and attach the row actions (View, Delete). * * @since 1.0.0 * @param array $item A singular item. * @return string ID and row actions HTML. */ protected function column_id( $item ) { // Build the URL to view this specific entry $view_url = add_query_arg( array( 'page' => 'tt24-forms-entries', 'action' => 'view', 'entry_id' => absint( $item['id'] ) ), admin_url( 'admin.php' ) ); // SECURITY: Create a secure deletion URL with a cryptographic nonce $delete_url = wp_nonce_url( add_query_arg( array( 'page' => 'tt24-forms-entries', 'action' => 'delete', 'entry_id' => absint( $item['id'] ) ), admin_url( 'admin.php' ) ), 'tt24_delete_entry_' . $item['id'] ); $actions = array( 'view' => sprintf( '%s', esc_url( $view_url ), esc_html__( 'View Details', '24tt-unrestricted-forms' ) ), 'delete' => sprintf( '%s', esc_url( $delete_url ), esc_js( __( 'Are you sure you want to permanently delete this entry?', '24tt-unrestricted-forms' ) ), esc_html__( 'Delete', '24tt-unrestricted-forms' ) ) ); // Bold the ID and append the actions underneath it return sprintf( '#%1$s%2$s', esc_html( $item['id'] ), $this->row_actions( $actions ) ); } /** * Render the bulk action checkbox for each row. * * @since 1.0.0 * @param array $item A singular item. * @return string The HTML for the checkbox. */ protected function column_cb( $item ) { return sprintf( '', absint( $item['id'] ) ); } /** * Define the bulk actions available for this table. * * @since 1.0.0 * @return array Array of bulk actions. */ protected function get_bulk_actions() { $actions = array( 'bulk-delete' => esc_html__( 'Delete Permanently', '24tt-unrestricted-forms' ) ); return $actions; } /** * Process bulk actions and single row actions. * * @since 1.0.0 * @return void */ public function process_bulk_action() { global $wpdb; // SECURITY 1: Verify capability if ( ! current_user_can( 'view_tt24_entries' ) ) { return; } $table_entries = $wpdb->prefix . 'tt24_entries'; $table_fields = $wpdb->prefix . 'tt24_entry_fields'; // 1. Handle Single Deletion (from the row actions) // FIX: Ignored nonce warning for read-only 'entry_id' URL param // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'delete' === $this->current_action() && isset( $_GET['entry_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $entry_id = absint( $_GET['entry_id'] ); // Verify the nonce specific to this entry check_admin_referer( 'tt24_delete_entry_' . $entry_id ); // Delete child relational data first, then parent entry $wpdb->delete( $table_fields, array( 'entry_id' => $entry_id ), array( '%d' ) ); $wpdb->delete( $table_entries, array( 'id' => $entry_id ), array( '%d' ) ); $redirect_url = add_query_arg( array( 'page' => 'tt24-forms-entries', 'deleted' => 1 ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect_url ); exit; } // 2. Handle Bulk Deletion (from the dropdown) if ( 'bulk-delete' === $this->current_action() ) { // Verify the generic bulk nonce check_admin_referer( 'bulk-' . $this->_args['plural'] ); $delete_ids = isset( $_REQUEST['bulk-delete'] ) ? array_map( 'absint', (array) wp_unslash( $_REQUEST['bulk-delete'] ) ) : array(); if ( ! empty( $delete_ids ) ) { foreach ( $delete_ids as $id ) { $wpdb->delete( $table_fields, array( 'entry_id' => $id ), array( '%d' ) ); $wpdb->delete( $table_entries, array( 'id' => $id ), array( '%d' ) ); } $redirect_url = add_query_arg( array( 'page' => 'tt24-forms-entries', 'deleted' => count( $delete_ids ) ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect_url ); exit; } } } /** * Prepare the data for display. * * @since 1.0.0 * @return void */ public function prepare_items() { global $wpdb; $table_entries = $wpdb->prefix . 'tt24_entries'; $table_forms = $wpdb->prefix . 'tt24_forms'; $per_page = 20; $current_page = $this->get_pagenum(); $offset = ( $current_page - 1 ) * $per_page; $columns = $this->get_columns(); $hidden = array(); $sortable = $this->get_sortable_columns(); $this->_column_headers = array( $columns, $hidden, $sortable ); // Process bulk actions before querying data $this->process_bulk_action(); $allowed_orderby = array( 'id', 'form_name', 'created_at' ); // FIX: Ignored nonce warning for read-only URL params // phpcs:ignore WordPress.Security.NonceVerification.Recommended $orderby_input = isset( $_GET['orderby'] ) ? sanitize_text_field( wp_unslash( $_GET['orderby'] ) ) : 'created_at'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order_input = isset( $_GET['order'] ) ? sanitize_text_field( wp_unslash( $_GET['order'] ) ) : 'DESC'; // 1. Validate against allowed values securely $orderby = in_array( $orderby_input, $allowed_orderby, true ) ? $orderby_input : 'created_at'; $order = ( strtolower( $order_input ) === 'asc' ) ? 'ASC' : 'DESC'; // 2. Wrap in esc_sql() specifically to satisfy the WP.org automated scanner $orderby = esc_sql( $orderby ); $order = esc_sql( $order ); // 3. Inline the SQL string inside prepare() to satisfy the scanner // FIX: Silencing false-positive DB warnings // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching $this->items = $wpdb->get_results( $wpdb->prepare( " SELECT e.id, e.ip_address, e.status, e.created_at, f.title as form_name FROM {$table_entries} e LEFT JOIN {$table_forms} f ON e.form_id = f.id ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d ", $per_page, $offset ), ARRAY_A ); // phpcs:enable // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching $total_items = $wpdb->get_var( "SELECT COUNT(id) FROM {$table_entries}" ); // phpcs:enable $this->set_pagination_args( array( 'total_items' => $total_items, 'per_page' => $per_page, 'total_pages' => ceil( $total_items / $per_page ) ) ); } }