logger = $logger; $this->settings_page = $settings_page; } /** * Hooks. * * @return void */ public function init() { $this->settings_page->init(); add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'admin_notices', array( $this, 'dependency_notice' ) ); add_action( 'admin_post_evlfl_generate_test_data', array( $this, 'handle_generate_test_data' ) ); add_action( 'admin_post_evlfl_export_csv', array( $this, 'handle_export_csv' ) ); } /** * Menu: top-level "Enrollment Logs" with "View Logs" and "Logs Settings". * * @return void */ public function register_menu() { $cap = EVLFL_Plugin::capability(); $title = __( 'Enrollment Logs', 'accesstrace-for-learndash' ); $this->hook_suffixes[] = (string) add_menu_page( $title, $title, $cap, self::PAGE_SLUG, array( $this, 'render_page' ), 'dashicons-list-view', 81 ); // Same slug as the parent: renames the first submenu entry from "Enrollment Logs" to "View Logs". add_submenu_page( self::PAGE_SLUG, __( 'View Logs', 'accesstrace-for-learndash' ), __( 'View Logs', 'accesstrace-for-learndash' ), $cap, self::PAGE_SLUG, array( $this, 'render_page' ) ); $this->hook_suffixes[] = (string) add_submenu_page( self::PAGE_SLUG, __( 'Logs Settings', 'accesstrace-for-learndash' ), __( 'Logs Settings', 'accesstrace-for-learndash' ), $cap, self::SETTINGS_SLUG, array( $this->settings_page, 'render' ) ); } /** * Enqueue CSS on our screen only. * * @param string $hook Hook suffix. * @return void */ public function enqueue_assets( $hook ) { if ( in_array( $hook, $this->hook_suffixes, true ) ) { $file = EVLFL_PLUGIN_DIR . 'assets/css/admin.css'; // filemtime, not the plugin version, so every edit to the stylesheet busts the // browser cache on its own; a visitor never has to hard-refresh after an update. $ver = file_exists( $file ) ? (string) filemtime( $file ) : EVLFL_VERSION; wp_enqueue_style( 'evl-admin', EVLFL_PLUGIN_URL . 'assets/css/admin.css', array( 'dashicons' ), $ver ); } } /** * Notice when LearnDash is missing (once per request; limited to relevant screens). * * @return void */ public function dependency_notice() { if ( self::$notice_printed || EVLFL_LearnDash::is_available() || ! current_user_can( EVLFL_Plugin::capability() ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $id = $screen ? $screen->id : ''; if ( ! in_array( $id, array( 'dashboard', 'plugins' ), true ) && false === strpos( $id, self::PAGE_SLUG ) && false === strpos( $id, self::SETTINGS_SLUG ) ) { return; } self::$notice_printed = true; printf( '

%s

', esc_html__( 'AccessTrace for LearnDash requires LearnDash to be active. No enrollment events will be logged until LearnDash is available.', 'accesstrace-for-learndash' ) ); } /** * Page router. * * @return void */ public function render_page() { if ( ! current_user_can( EVLFL_Plugin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'accesstrace-for-learndash' ), '', array( 'response' => 403 ) ); } $event_id = isset( $_GET['evlfl_event'] ) ? absint( wp_unslash( $_GET['evlfl_event'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view. if ( $event_id > 0 ) { $row = $this->logger->get( $event_id ); $list_url = add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ); $view = $row ? EVLFL_Log_Details::build( $row ) : null; include EVLFL_PLUGIN_DIR . 'admin/views/log-details.php'; return; } self::load_list_table(); $stats = $this->logger->status_counts(); $table = new EVLFL_Log_List_Table( $this->logger ); $table->set_overall_total( $stats['total'] ); $table->prepare_items(); $test_data_enabled = EVLFL_Test_Data::is_enabled(); $notice = $this->read_notice(); $refresh_url = $this->refresh_url(); $export_url = $this->export_url(); include EVLFL_PLUGIN_DIR . 'admin/views/logs.php'; } /** * The list table class (and its parent, WP_List_Table) are not loaded by WordPress on * every admin request - admin-post.php, for one, does not load them. Everything that * uses EVLFL_Log_List_Table goes through here first. * * @return void */ public static function load_list_table() { require_once EVLFL_PLUGIN_DIR . 'admin/class-log-list-table.php'; } /** * "Refresh": this screen with the current filters, sort and page, so it re-runs the same view. * * @return string Raw URL (escape on output). */ public function refresh_url() { self::load_list_table(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only view state, every value validated. $args = array_merge( EVLFL_Log_List_Table::url_args( EVLFL_Log_List_Table::parse_filters( $_GET ) ), EVLFL_Log_List_Table::sort_args( $_GET ) ); $paged = isset( $_GET['paged'] ) && is_scalar( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 0; // phpcs:enable if ( $paged > 1 ) { $args['paged'] = $paged; } return EVLFL_Log_List_Table::list_url( $args ); } /** * "Export CSV": admin-post endpoint with the current filters and a nonce. * * @return string Raw URL (escape on output). */ public function export_url() { self::load_list_table(); $args = EVLFL_Log_List_Table::url_args( EVLFL_Log_List_Table::parse_filters( $_GET ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- validated. $url = add_query_arg( rawurlencode_deep( array_merge( array( 'action' => 'evlfl_export_csv' ), $args ) ), admin_url( 'admin-post.php' ) ); return add_query_arg( '_wpnonce', wp_create_nonce( 'evlfl_export_csv' ), $url ); } /** * Download the currently filtered events as CSV. Read-only: nothing is changed. * * @return void */ public function handle_export_csv() { if ( ! current_user_can( EVLFL_Plugin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'accesstrace-for-learndash' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'evlfl_export_csv' ); self::load_list_table(); $filters = EVLFL_Log_List_Table::parse_filters( $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above; values validated by parse_filters(). $filters['search_email'] = EVLFL_Log_List_Table::can_search_email(); $filename = 'enrollment-logs-' . wp_date( 'Y-m-d-His' ) . '.csv'; if ( ! $this->send_download_headers( $filename ) ) { wp_die( esc_html__( 'The export could not start because another plugin already sent output. Please try again.', 'accesstrace-for-learndash' ), '', array( 'response' => 500 ) ); } self::allow_long_request(); $out = fopen( 'php://output', 'w' ); $this->write_csv( $out, $filters ); fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the php://output stream, not a file. $this->end_request(); } /** * A big log takes a while to stream: use WordPress's (higher) admin memory limit. * Rows are streamed in keyset batches with names looked up once per batch, so the * export stays fast without changing the PHP time limit. * * @return void */ private static function allow_long_request() { if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'admin' ); } } /** * CSV download headers. * * @param string $filename File name offered to the browser. * @return bool False when output has already started (headers can no longer be sent). */ protected function send_download_headers( $filename ) { if ( headers_sent() ) { return false; } nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' ); header( 'X-Content-Type-Options: nosniff' ); return true; } /** * Ends the download request. A separate method so tests can run the handler without exiting. * * @return void */ protected function end_request() { exit; } /** * Write the CSV: UTF-8 BOM (so Excel shows accented names correctly), a header row, then one * row per matching event, newest first. * * @param resource $handle Writable stream. * @param array $filters Output of EVLFL_Log_List_Table::parse_filters(). * @return int Number of event rows written. */ public function write_csv( $handle, array $filters ) { fwrite( $handle, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming to php://output. self::put_csv( $handle, array( __( 'Event ID', 'accesstrace-for-learndash' ), __( 'Date (site time)', 'accesstrace-for-learndash' ), __( 'Date (UTC)', 'accesstrace-for-learndash' ), __( 'User', 'accesstrace-for-learndash' ), __( 'User ID', 'accesstrace-for-learndash' ), __( 'Course', 'accesstrace-for-learndash' ), __( 'Course ID', 'accesstrace-for-learndash' ), __( 'Action', 'accesstrace-for-learndash' ), __( 'Source', 'accesstrace-for-learndash' ), __( 'Trigger', 'accesstrace-for-learndash' ), __( 'Status', 'accesstrace-for-learndash' ), __( 'Confidence', 'accesstrace-for-learndash' ), __( 'Reason', 'accesstrace-for-learndash' ), ) ); // Names are looked up once per batch (not once per row), and only the current batch's // names are kept, so memory stays flat however long the log is. $users = array(); $on_batch = static function ( array $rows ) use ( &$users ) { $users = array(); $ids = array_values( array_unique( array_map( 'intval', wp_list_pluck( $rows, 'user_id' ) ) ) ); $found = get_users( array( 'include' => $ids, 'fields' => array( 'ID', 'display_name' ), 'blog_id' => 0, 'count_total' => false, ) ); foreach ( $found as $u ) { $users[ (int) $u->ID ] = (string) $u->display_name; } if ( function_exists( '_prime_post_caches' ) ) { _prime_post_caches( array_values( array_unique( array_map( 'intval', wp_list_pluck( $rows, 'course_id' ) ) ) ), false, false ); } }; return $this->logger->each( $filters, static function ( array $row ) use ( $handle, &$users ) { $name = isset( $users[ $row['user_id'] ] ) ? $users[ $row['user_id'] ] : null; $course = get_post( $row['course_id'] ); self::put_csv( $handle, array( (int) $row['id'], EVLFL_Log_Details::format_time( $row['event_time'] ), (string) $row['event_time'], /* translators: %d: user ID */ null !== $name ? $name : sprintf( __( '[Deleted User #%d]', 'accesstrace-for-learndash' ), $row['user_id'] ), (int) $row['user_id'], /* translators: %d: course ID */ $course ? $course->post_title : sprintf( __( '[Deleted Course #%d]', 'accesstrace-for-learndash' ), $row['course_id'] ), (int) $row['course_id'], EVLFL_Event::label( 'action', $row['action'] ), EVLFL_Event::label( 'source', $row['source'] ), EVLFL_Event::label( 'trigger', $row['event_trigger'] ), EVLFL_Event::label( 'status', $row['status'] ), EVLFL_Event::label( 'confidence', $row['confidence'] ), (string) $row['verification_reason'], ) ); }, 500, $on_batch ); } /** * One RFC 4180 CSV line. Text cells that a spreadsheet would run as a formula (they start * with = + - @, tab or CR - e.g. a course titled "=HYPERLINK(...)") get a leading * apostrophe, so opening the export can never execute anything. * * @param resource $handle Writable stream. * @param array $cells Cell values. * @return void */ public static function put_csv( $handle, array $cells ) { foreach ( $cells as $i => $cell ) { if ( is_int( $cell ) ) { continue; } $cell = (string) $cell; if ( '' !== $cell && false !== strpos( "=+-@\t\r", $cell[0] ) ) { $cell = "'" . $cell; } $cells[ $i ] = $cell; } fputcsv( $handle, $cells, ',', '"', '' ); } /** * Allowlisted notice key from the query string. * * @return string */ private function read_notice() { $key = isset( $_GET['evlfl_notice'] ) ? sanitize_key( wp_unslash( $_GET['evlfl_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return in_array( $key, array( 'generated' ), true ) ? $key : ''; } /** * Generate sample events (constant-gated, capability + nonce protected). * * @return void */ public function handle_generate_test_data() { if ( ! current_user_can( EVLFL_Plugin::capability() ) || ! EVLFL_Test_Data::is_enabled() ) { wp_die( esc_html__( 'You do not have permission to do that.', 'accesstrace-for-learndash' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'evlfl_generate_test_data' ); EVLFL_Test_Data::generate( $this->logger, get_current_user_id() ); wp_safe_redirect( add_query_arg( array( 'page' => self::PAGE_SLUG, 'evlfl_notice' => 'generated', ), admin_url( 'admin.php' ) ) ); exit; } }