self::ID, 'name' => __( 'Reports', 'forensix-digital' ), 'description' => __( 'Shows how many problems each of your last scans found, fixed or left open, and exports your open findings as a CSV file for a spreadsheet.', 'forensix-digital' ), 'category' => 'reports', 'edition' => Edition::FREE, 'dependencies' => array( SiteScanApp::ID ), 'features' => array( 'F-MONITOR-01' => Edition::FREE, 'F-MONITOR-02' => Edition::FREE, 'F-MONITOR-04' => Edition::FREE, ), 'deactivation_effects' => array( __( 'The Reports page is removed from the menu, and the CSV export stops working.', 'forensix-digital' ), __( 'Your scans and findings stay as they are.', 'forensix-digital' ), ), 'factory' => static fn(): self => new self(), ) ); } /** * Hooks the app while it is active. */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'admin_post_' . self::EXPORT_ACTION, array( $this, 'export' ) ); add_filter( 'forensix_admin_screens', array( $this, 'add_screen' ) ); } /** * Registers the history route. */ public function register_routes(): void { register_rest_route( Api::NAMESPACE, '/monitor/history', array( 'methods' => 'GET', 'callback' => array( $this, 'history' ), 'permission_callback' => static fn(): bool => current_user_can( Capabilities::VIEW_FINDINGS ), ) ); register_rest_route( Api::NAMESPACE, '/monitor/changes', array( 'methods' => 'GET', 'callback' => array( $this, 'changes' ), 'permission_callback' => static fn(): bool => current_user_can( Capabilities::VIEW_FINDINGS ), 'args' => array( 'scan' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); } /** * What one scan changed. Without a scan id, the newest finished scan. * * @param \WP_REST_Request $request Request. */ public function changes( \WP_REST_Request $request ): \WP_REST_Response { $store = Plugin::container()->get( FindingStore::class ); $scan_id = absint( $request->get_param( 'scan' ) ); $scan = $scan_id > 0 ? $store->scan( $scan_id ) : ( $store->recent_scans( SiteScanApp::ID, 1 )[0] ?? null ); if ( null === $scan || SiteScanApp::ID !== $scan['app'] ) { return new \WP_REST_Response( array( 'scan' => null ), 404 ); } return new \WP_REST_Response( HistoryReport::changes( $scan, $store->changes( $scan ) ) ); } /** * Adds the Reports page to the Forensix menu. * * @param array $screens Screens. * @phpstan-param array $screens * @return array */ public function add_screen( array $screens ): array { $screens['forensix-reports'] = array( 'key' => 'reports', 'title' => __( 'Reports', 'forensix-digital' ), 'capability' => Capabilities::VIEW_FINDINGS, ); return $screens; } /** * The last scans, the trend and the address of the CSV export. */ public function history(): \WP_REST_Response { $store = Plugin::container()->get( FindingStore::class ); $rows = HistoryReport::rows( $store->recent_scans( SiteScanApp::ID, HistoryReport::limit() ) ); return new \WP_REST_Response( array( 'scans' => $rows, 'trend' => HistoryReport::trend( $rows ), 'limit' => HistoryReport::limit(), 'export_url' => add_query_arg( '_wpnonce', wp_create_nonce( self::EXPORT_ACTION ), admin_url( 'admin-post.php?action=' . self::EXPORT_ACTION ) ), // Not wp_nonce_url(): its HTML-escaped '&' breaks the link in the screen script. ) ); } /** * Sends the open findings as a CSV download. */ public function export(): void { if ( ! current_user_can( Capabilities::VIEW_FINDINGS ) ) { wp_die( esc_html__( 'You are not allowed to export findings.', 'forensix-digital' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::EXPORT_ACTION ); $store = Plugin::container()->get( FindingStore::class ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="forensix-findings-' . gmdate( 'Y-m-d' ) . '.csv"' ); echo "\xEF\xBB\xBF"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Byte order mark so spreadsheets read UTF-8. foreach ( CsvExport::lines( $store, SiteScanApp::ID ) as $line ) { echo $line; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV file, not HTML; cells are quoted and formula characters are neutralised. } exit; } /** * Nothing to set up. */ public function activate(): void {} /** * Removes the hooks. */ public function deactivate(): void { remove_action( 'rest_api_init', array( $this, 'register_routes' ) ); remove_action( 'admin_post_' . self::EXPORT_ACTION, array( $this, 'export' ) ); remove_filter( 'forensix_admin_screens', array( $this, 'add_screen' ) ); } /** * Nothing stored. */ public function delete_data(): void {} }