rest_url( self::REST_NS . '/analytics' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'dashboardUrl' => admin_url( 'admin.php?page=' . self::PAGE_SLUG ), 'settingsUrl' => admin_url( 'options-general.php?page=' . Causewise_Settings::PAGE_SLUG ), ); } /** * Top-level analytics screen. Tracking settings stay under Settings. */ public static function register_page() { add_menu_page( __( 'CauseWise Analytics', 'causewise-analytics' ), __( 'CauseWise', 'causewise-analytics' ), Causewise_Settings::CAPABILITY, self::PAGE_SLUG, array( __CLASS__, 'render_page' ), 'dashicons-chart-area', 30 ); } /** * Print the mount point for the React dashboard. */ public static function render_page() { if ( ! self::can_view() ) { wp_die( esc_html__( 'Sorry, you are not allowed to view CauseWise Analytics.', 'causewise-analytics' ), 403 ); } $range = '7d'; if ( isset( $_GET['range'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view filter. The value is sanitized and is not saved. $range = Causewise_Api::sanitize_range( sanitize_text_field( wp_unslash( $_GET['range'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view filter. The value is sanitized and is not saved. } ?>


id ) || 'dashboard' !== $screen->id ) { return $hidden; } return array_values( array_diff( $hidden, self::widget_ids() ) ); } /** * Dashboard widgets. Registered late so cleanup on wp_dashboard_setup * does not drop them. wp_add_dashboard_widget adds nothing when the * current screen is missing. */ public static function register_widgets() { if ( ! self::can_view() ) { return; } self::ensure_dashboard_screen(); wp_add_dashboard_widget( 'causewise_stats', __( 'CauseWise Stats', 'causewise-analytics' ), array( __CLASS__, 'render_stats_widget' ), null, null, 'normal', 'high' ); $widgets = array( 'causewise_pageviews' => array( 'title' => __( 'CauseWise Page Views', 'causewise-analytics' ), 'variant' => 'pageviews', 'context' => 'side', ), 'causewise_sessions' => array( 'title' => __( 'CauseWise Sessions', 'causewise-analytics' ), 'variant' => 'sessions', 'context' => 'side', ), 'causewise_traffic' => array( 'title' => __( 'CauseWise Traffic', 'causewise-analytics' ), 'variant' => 'traffic', 'context' => 'normal', ), 'causewise_top_pages' => array( 'title' => __( 'CauseWise Top Pages', 'causewise-analytics' ), 'variant' => 'pages', 'context' => 'normal', ), ); foreach ( $widgets as $widget_id => $widget ) { wp_add_dashboard_widget( $widget_id, $widget['title'], static function () use ( $widget ) { Causewise_Admin::render_widget( $widget['variant'] ); }, static function () use ( $widget_id ) { Causewise_Admin::render_control( $widget_id ); }, null, $widget['context'] ); } } /** * Saved date range for one widget. * * @param string $widget_id Widget ID. * @return string */ public static function widget_range( $widget_id ) { $options = get_option( 'dashboard_widget_options', array() ); $range = '7d'; if ( is_array( $options ) && isset( $options[ $widget_id ]['range'] ) ) { $range = $options[ $widget_id ]['range']; } return Causewise_Api::sanitize_range( $range ); } /** * Combined stats widget for the main Dashboard column. */ public static function render_stats_widget() { $range = self::widget_range( 'causewise_stats' ); echo '
'; echo ''; } /** * Compact widget body plus a link to the full screen. * * @param string $variant pageviews, sessions, traffic, or pages. */ public static function render_widget( $variant ) { $widget_id = self::widget_id_for_variant( $variant ); $range = self::widget_range( $widget_id ); echo '
'; echo ''; } /** * Date-range control shown by the core Configure link. * * @param string $widget_id Widget ID. */ public static function render_control( $widget_id ) { $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( 'POST' === $method && isset( $_POST['widget_id'] ) ) { $posted_id = sanitize_text_field( wp_unslash( $_POST['widget_id'] ) ); if ( $posted_id === $widget_id ) { check_admin_referer( 'edit-dashboard-widget_' . $widget_id, 'dashboard-widget-nonce' ); $posted_ranges = isset( $_POST['causewise_range'] ) ? wp_unslash( $_POST['causewise_range'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each selected value is sanitized before it is saved. $posted_range = '7d'; if ( is_array( $posted_ranges ) && isset( $posted_ranges[ $widget_id ] ) ) { $posted_range = sanitize_text_field( $posted_ranges[ $widget_id ] ); } $options = get_option( 'dashboard_widget_options', array() ); if ( ! is_array( $options ) ) { $options = array(); } if ( ! isset( $options[ $widget_id ] ) || ! is_array( $options[ $widget_id ] ) ) { $options[ $widget_id ] = array(); } $options[ $widget_id ]['range'] = Causewise_Api::sanitize_range( $posted_range ); update_option( 'dashboard_widget_options', $options ); } } $range = self::widget_range( $widget_id ); $choices = array( 'today' => __( 'Today', 'causewise-analytics' ), '7d' => __( 'Last 7 days', 'causewise-analytics' ), '30d' => __( 'Last 30 days', 'causewise-analytics' ), ); echo '

'; echo ''; } /** * Load the dashboard bundle on the Home screen and the analytics screen. * * @param string $hook Current admin page hook. */ public static function enqueue( $hook ) { if ( ! self::can_view() ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $on_dashboard = 'index.php' === $hook || ( is_object( $screen ) && isset( $screen->id ) && 'dashboard' === $screen->id ); $on_analytics = 'toplevel_page_' . self::PAGE_SLUG === $hook; if ( ! $on_dashboard && ! $on_analytics ) { return; } wp_enqueue_style( 'causewise-analytics-admin', plugins_url( 'assets/shell.css', CAUSEWISE_ANALYTICS_FILE ), array(), CAUSEWISE_ANALYTICS_VERSION ); wp_enqueue_script( 'causewise-analytics-admin', plugins_url( 'assets/dashboard.js', CAUSEWISE_ANALYTICS_FILE ), array(), CAUSEWISE_ANALYTICS_VERSION, true ); wp_localize_script( 'causewise-analytics-admin', 'causewiseAnalytics', self::client_config() ); } /** * Authenticated proxy. The management key never leaves PHP. */ public static function register_rest() { register_rest_route( self::REST_NS, '/analytics', array( 'methods' => 'GET', 'permission_callback' => array( __CLASS__, 'can_view' ), 'callback' => array( __CLASS__, 'rest_analytics' ), 'args' => array( 'range' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => array( 'Causewise_Api', 'sanitize_range' ), ), 'view' => array( 'type' => 'string', 'required' => false, ), 'filter' => array( 'type' => 'string', 'required' => false, ), 'cursor' => array( 'type' => 'string', 'required' => false, ), 'limit' => array( 'type' => 'integer', 'required' => false, ), 'section' => array( 'type' => 'string', 'required' => false, ), ), ) ); $layout_args = array( 'methods' => 'GET', 'permission_callback' => array( __CLASS__, 'can_view' ), 'callback' => array( __CLASS__, 'rest_get_layout' ), ); register_rest_route( self::REST_NS, '/dashboard-layout', $layout_args ); register_rest_route( self::REST_NS, '/dashboard-layout', array( 'methods' => 'POST', 'permission_callback' => array( __CLASS__, 'can_view' ), 'callback' => array( __CLASS__, 'rest_save_layout' ), ) ); register_rest_route( self::REST_NS, '/dashboard-layout', array( 'methods' => 'DELETE', 'permission_callback' => array( __CLASS__, 'can_view' ), 'callback' => array( __CLASS__, 'rest_reset_layout' ), ) ); } /** * @param mixed $request REST request. * @return mixed */ public static function rest_analytics( $request ) { $range = '7d'; $view = 'dashboard'; $filter = 'all'; $cursor = ''; $limit = 20; $section = ''; if ( is_object( $request ) && method_exists( $request, 'get_param' ) ) { $range = Causewise_Api::sanitize_range( $request->get_param( 'range' ) ); $view = (string) $request->get_param( 'view' ); $filter = Causewise_Api::sanitize_event_filter( $request->get_param( 'filter' ) ); $cursor = Causewise_Api::sanitize_event_cursor( $request->get_param( 'cursor' ) ); $limit = Causewise_Api::sanitize_event_limit( $request->get_param( 'limit' ) ); $raw = $request->get_param( 'section' ); if ( is_string( $raw ) && '' !== trim( $raw ) ) { $section = Causewise_Embeds::sanitize_kind( $raw ); if ( '' === $section ) { return self::rest_result( array( 'ok' => false, 'status' => 400, 'code' => 'invalid_section', 'message' => __( 'Choose a CauseWise analytics block.', 'causewise-analytics' ), ) ); } } } if ( '' !== $section ) { $display = 8; if ( is_object( $request ) && method_exists( $request, 'get_param' ) ) { $display = Causewise_Embeds::sanitize_limit( $request->get_param( 'limit' ) ); } $result = Causewise_Embeds::load( $range, $section, $display ); } elseif ( 'events' === $view ) { $result = Causewise_Api::load_events( $range, $filter, $cursor, $limit ); } else { $view = 'overview' === $view ? 'overview' : 'dashboard'; $result = Causewise_Api::load( $range, $view ); } return self::rest_result( $result ); } /** * Saved layout for the current user and one dashboard instance. * * @param mixed $request REST request. * @return mixed */ public static function rest_get_layout( $request ) { $denied = self::layout_read_denied(); if ( null !== $denied ) { return $denied; } $instance = self::layout_instance_from_request( $request ); if ( '' === $instance ) { return self::layout_error( 400, 'invalid_instance', __( 'Choose a dashboard.', 'causewise-analytics' ) ); } return self::rest_result( array( 'ok' => true, 'status' => 200, 'instance' => $instance, 'layout' => self::read_user_layout( $instance ), ) ); } /** * Replace the current user's layout for one dashboard. The user id in the body is ignored. * * @param mixed $request REST request. * @return mixed */ public static function rest_save_layout( $request ) { $denied = self::layout_write_denied( $request ); if ( null !== $denied ) { return $denied; } $instance = self::layout_instance_from_request( $request ); $layout = self::sanitize_stored_layout( self::request_value( $request, 'layout' ) ); if ( '' === $instance || null === $layout ) { return self::layout_error( 400, 'invalid_layout', __( 'The dashboard layout could not be saved.', 'causewise-analytics' ) ); } self::write_user_layout( $instance, $layout ); return self::rest_result( array( 'ok' => true, 'status' => 200, 'instance' => $instance, 'layout' => $layout, ) ); } /** * Remove the current user's layout for one dashboard. * * @param mixed $request REST request. * @return mixed */ public static function rest_reset_layout( $request ) { $denied = self::layout_write_denied( $request ); if ( null !== $denied ) { return $denied; } $instance = self::layout_instance_from_request( $request ); if ( '' === $instance ) { return self::layout_error( 400, 'invalid_instance', __( 'Choose a dashboard.', 'causewise-analytics' ) ); } self::delete_user_layout( $instance ); return self::rest_result( array( 'ok' => true, 'status' => 200, 'instance' => $instance, 'layout' => null, ) ); } /** * @param mixed $layout Raw preference. * @return array{v: int, panels: array}|null */ public static function sanitize_stored_layout( $layout ) { if ( ! is_array( $layout ) ) { return null; } foreach ( array_keys( $layout ) as $key ) { if ( 'v' !== $key && 'panels' !== $key ) { return null; } } if ( ! isset( $layout['v'] ) || ! is_int( $layout['v'] ) || 1 !== $layout['v'] ) { return null; } if ( ! isset( $layout['panels'] ) || ! is_array( $layout['panels'] ) || count( $layout['panels'] ) > 20 ) { return null; } $allowed = array( 'overview' => true, 'traffic' => true, 'top-pages' => true, 'referrers' => true, 'goals' => true, 'funnels' => true, 'events' => true, ); $widths = array( 4 => true, 6 => true, 8 => true, 12 => true ); $seen = array(); $panels = array(); foreach ( $layout['panels'] as $panel ) { if ( ! is_array( $panel ) ) { return null; } foreach ( array_keys( $panel ) as $key ) { if ( 'id' !== $key && 'order' !== $key && 'visible' !== $key && 'w' !== $key ) { return null; } } if ( ! isset( $panel['id'], $panel['order'], $panel['visible'], $panel['w'] ) ) { return null; } $id = $panel['id']; if ( ! is_string( $id ) || ! isset( $allowed[ $id ] ) || isset( $seen[ $id ] ) ) { return null; } if ( ! is_int( $panel['order'] ) || $panel['order'] < 0 || $panel['order'] > 999 ) { return null; } if ( ! is_bool( $panel['visible'] ) || ! is_int( $panel['w'] ) || ! isset( $widths[ $panel['w'] ] ) ) { return null; } $seen[ $id ] = true; $panels[] = array( 'id' => $id, 'order' => $panel['order'], 'visible' => $panel['visible'], 'w' => $panel['w'], ); } return array( 'v' => 1, 'panels' => $panels, ); } /** * @param string $instance Dashboard instance. * @return array{v: int, panels: array}|null */ public static function read_user_layout( $instance ) { $stored = self::layout_collection(); return isset( $stored[ $instance ] ) ? $stored[ $instance ] : null; } /** * @param string $instance Dashboard instance. * @param array{v: int, panels: array} $layout Sanitized preference. */ public static function write_user_layout( $instance, $layout ) { $user_id = self::layout_user_id(); if ( $user_id <= 0 || ! function_exists( 'update_user_meta' ) ) { return; } $stored = self::layout_collection(); $stored[ $instance ] = $layout; update_user_meta( $user_id, self::LAYOUT_META, array( 'v' => 1, 'dashboards' => $stored, ) ); } /** * @param string $instance Dashboard instance. */ public static function delete_user_layout( $instance ) { $user_id = self::layout_user_id(); if ( $user_id <= 0 ) { return; } $stored = self::layout_collection(); unset( $stored[ $instance ] ); if ( empty( $stored ) && function_exists( 'delete_user_meta' ) ) { delete_user_meta( $user_id, self::LAYOUT_META ); return; } if ( function_exists( 'update_user_meta' ) ) { update_user_meta( $user_id, self::LAYOUT_META, array( 'v' => 1, 'dashboards' => $stored, ) ); } } /** * @return array}> */ private static function layout_collection() { $user_id = self::layout_user_id(); if ( $user_id <= 0 || ! function_exists( 'get_user_meta' ) ) { return array(); } $raw = get_user_meta( $user_id, self::LAYOUT_META, true ); if ( ! is_array( $raw ) || ! isset( $raw['v'], $raw['dashboards'] ) || 1 !== $raw['v'] || ! is_array( $raw['dashboards'] ) ) { return array(); } $stored = array(); foreach ( $raw['dashboards'] as $instance => $layout ) { $clean_instance = Causewise_Embeds::sanitize_instance( $instance ); $clean_layout = self::sanitize_stored_layout( $layout ); if ( '' === $clean_instance || null === $clean_layout ) { continue; } $stored[ $clean_instance ] = $clean_layout; } return $stored; } /** * @return int */ private static function layout_user_id() { if ( ! self::can_view() || ! function_exists( 'get_current_user_id' ) ) { return 0; } $user_id = (int) get_current_user_id(); return $user_id > 0 ? $user_id : 0; } /** * @return mixed|null */ private static function layout_read_denied() { if ( self::layout_user_id() <= 0 ) { return self::layout_error( 401, 'rest_forbidden', __( 'Sign in with a WordPress account that can manage CauseWise.', 'causewise-analytics' ) ); } return null; } /** * @param mixed $request REST request. * @return mixed|null */ private static function layout_write_denied( $request ) { $denied = self::layout_read_denied(); if ( null !== $denied ) { return $denied; } $nonce = self::request_header( $request, 'X-WP-Nonce' ); if ( ! function_exists( 'wp_verify_nonce' ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return self::layout_error( 403, 'rest_cookie_invalid_nonce', __( 'The dashboard layout could not be saved.', 'causewise-analytics' ) ); } return null; } /** * @param mixed $request REST request. * @return string */ private static function layout_instance_from_request( $request ) { return Causewise_Embeds::sanitize_instance( self::request_value( $request, 'instance' ) ); } /** * @param mixed $request REST request. * @param string $key Parameter name. * @return mixed */ private static function request_value( $request, $key ) { if ( is_object( $request ) && method_exists( $request, 'get_param' ) ) { return $request->get_param( $key ); } return null; } /** * @param mixed $request REST request. * @param string $name Header name. * @return string */ private static function request_header( $request, $name ) { if ( ! is_object( $request ) || ! method_exists( $request, 'get_header' ) ) { return ''; } return (string) $request->get_header( $name ); } /** * @param int $status HTTP status. * @param string $code Error code. * @param string $message Viewer message. * @return mixed */ private static function layout_error( $status, $code, $message ) { return self::rest_result( array( 'ok' => false, 'status' => $status, 'code' => $code, 'message' => $message, ) ); } /** * JSON result with private cache headers. The management key is not part of the body. * * @param array $result Analytics payload. * @return mixed */ private static function rest_result( $result ) { $status = isset( $result['status'] ) ? (int) $result['status'] : 200; unset( $result['status'] ); if ( function_exists( 'rest_ensure_response' ) ) { $response = rest_ensure_response( $result ); if ( is_object( $response ) && method_exists( $response, 'set_status' ) ) { $response->set_status( $status ); } if ( is_object( $response ) && method_exists( $response, 'header' ) ) { foreach ( Causewise_Embeds::private_cache_headers() as $name => $value ) { $response->header( $name, $value ); } } return $response; } return $result; } /** * wp_add_dashboard_widget reads the current screen. Without one, the meta box is never added. */ private static function ensure_dashboard_screen() { if ( function_exists( 'get_current_screen' ) && get_current_screen() ) { return; } if ( function_exists( 'set_current_screen' ) ) { set_current_screen( 'dashboard' ); } } /** * @param string $variant Widget variant. * @return string */ private static function widget_id_for_variant( $variant ) { $map = array( 'pageviews' => 'causewise_pageviews', 'sessions' => 'causewise_sessions', 'traffic' => 'causewise_traffic', 'pages' => 'causewise_top_pages', ); return isset( $map[ $variant ] ) ? $map[ $variant ] : 'causewise_pageviews'; } }