get_route(); if ( 0 !== strpos( $route, '/' . self::NS . '/' ) || ! Saddle_Analytics_Migrate::blocking() ) { return $response; } foreach ( array( '/hit', '/dur', '/exit' ) as $beacon ) { if ( '/' . self::NS . $beacon === $route ) { return $response; } } return Saddle_Analytics_Migrate::busy_error(); } public function register_routes(): void { $range = array( 'type' => 'string', 'enum' => Saddle_Analytics_Report::RANGES, 'default' => '30d', ); // A custom window overrides the range enum. Dates are validated here and // again as a pair in window_error() — the schema can't see one arg from // the other, so from > to and future dates are caught in the callback. $date = array( 'type' => 'string', 'validate_callback' => static function ( $value ) { return '' === $value || Saddle_Analytics_Window::valid_date( (string) $value ); }, ); $window = array( 'from' => $date, 'to' => $date, 'compare' => array( 'type' => 'boolean', 'default' => false ), ); register_rest_route( self::NS, '/report', array( 'methods' => 'GET', 'callback' => array( $this, 'report' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'dimension' => array( 'type' => 'string', 'enum' => Saddle_Analytics_Dimensions::keys(), 'required' => true ), 'metric' => array( 'type' => 'string', 'enum' => Saddle_Analytics_Metric::all(), 'default' => Saddle_Analytics_Metric::VISITORS ), 'range' => $range, 'filters' => array( 'type' => 'object', 'default' => array() ), ) + $window, ) ); register_rest_route( self::NS, '/summary', array( 'methods' => 'GET', 'callback' => array( $this, 'summary' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'range' => $range, 'filters' => array( 'type' => 'object', 'default' => array() ) ) + $window, ) ); register_rest_route( self::NS, '/timeseries', array( 'methods' => 'GET', 'callback' => array( $this, 'timeseries' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'range' => $range, 'filters' => array( 'type' => 'object', 'default' => array() ) ) + $window, ) ); register_rest_route( self::NS, '/crawlers', array( 'methods' => 'GET', 'callback' => array( $this, 'crawlers' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'range' => $range ) + $window, ) ); register_rest_route( self::NS, '/realtime', array( 'methods' => 'GET', 'callback' => array( $this, 'realtime' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), ) ); register_rest_route( self::NS, '/journeys', array( 'methods' => 'GET', 'callback' => array( $this, 'journeys' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'limit' => array( 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 200 ), 'offset' => array( 'type' => 'integer', 'default' => 0, 'minimum' => 0 ), ), ) ); // The id is 32 hex chars — constrained in the route pattern AND revalidated // in the callback before it reaches SQL (rule 3). register_rest_route( self::NS, '/journey/(?P[a-f0-9]{32})', array( 'methods' => 'GET', 'callback' => array( $this, 'journey' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'visitor_id' => array( 'type' => 'string', 'required' => true ), ), ) ); register_rest_route( self::NS, '/meta', array( 'methods' => 'GET', 'callback' => array( $this, 'meta' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), ) ); register_rest_route( self::NS, '/settings', array( 'methods' => 'GET', 'callback' => array( $this, 'settings' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), ) ); register_rest_route( self::NS, '/settings', array( 'methods' => 'POST', 'callback' => array( $this, 'save_settings' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'track_admins' => array( 'type' => 'boolean' ), 'attribution' => array( 'type' => 'string', 'enum' => array( 'first', 'last' ) ), 'delete_data_on_uninstall' => array( 'type' => 'boolean' ), 'remember_visitors' => array( 'type' => 'boolean' ), 'retention_days' => array( 'type' => 'integer', 'minimum' => Saddle_Analytics_Settings::RETENTION_MIN, 'maximum' => Saddle_Analytics_Settings::RETENTION_MAX ), ), ) ); } /** Every dashboard route: administrators only, the same check as the page that calls them. */ public static function can_manage(): bool { return current_user_can( 'manage_options' ); } public function settings() { return rest_ensure_response( self::display_settings() ); } public function save_settings( WP_REST_Request $r ) { $incoming = array_intersect_key( $r->get_params(), array( 'track_admins' => 1, 'attribution' => 1, 'delete_data_on_uninstall' => 1, 'remember_visitors' => 1, 'retention_days' => 1, ) ); Saddle_Analytics_Settings::update( $incoming ); return rest_ensure_response( self::display_settings() ); } private static function display_settings(): array { return Saddle_Analytics_Settings::all(); } public function report( WP_REST_Request $r ) { $dimension = Saddle_Analytics_Dimensions::get( (string) $r['dimension'] ); if ( ! $dimension ) { return new WP_Error( 'saddle_analytics_bad_dimension', __( 'Unknown dimension.', 'saddle-analytics' ), array( 'status' => 400 ) ); } $error = self::window_error( $r ); if ( $error ) { return $error; } $rows = $this->report->table( $dimension, (string) $r['metric'], (string) $r['range'], (array) $r['filters'], 100, self::date( $r, 'from' ), self::date( $r, 'to' ) ); return rest_ensure_response( array( 'dimension' => $dimension->key, 'label' => $dimension->label, 'format' => $dimension->format, 'rows' => $rows, ) ); } public function summary( WP_REST_Request $r ) { $error = self::window_error( $r ); if ( $error ) { return $error; } return rest_ensure_response( $this->report->summary( (string) $r['range'], self::date( $r, 'from' ), self::date( $r, 'to' ), (bool) $r['compare'], (array) $r['filters'] ) ); } public function timeseries( WP_REST_Request $r ) { $error = self::window_error( $r ); if ( $error ) { return $error; } return rest_ensure_response( $this->report->timeseries( (string) $r['range'], self::date( $r, 'from' ), self::date( $r, 'to' ), (bool) $r['compare'], (array) $r['filters'] ) ); } public function crawlers( WP_REST_Request $r ) { $error = self::window_error( $r ); if ( $error ) { return $error; } return rest_ensure_response( $this->crawler_report->crawlers( (string) $r['range'], self::date( $r, 'from' ), self::date( $r, 'to' ) ) ); } /** An absent or blank date arg is null, not ''. */ private static function date( WP_REST_Request $r, string $key ): ?string { $value = (string) ( $r[ $key ] ?? '' ); return '' === $value ? null : $value; } /** * The checks the args schema can't make, because each arg is validated alone: * a lone --to, a reversed pair, and a window running into the future. * Malformed dates are already rejected by the schema (400). */ private static function window_error( WP_REST_Request $r ): ?WP_Error { return self::window_error_for( self::date( $r, 'from' ), self::date( $r, 'to' ) ); } /** The same checks for any caller holding a from/to pair (the abilities reuse it). */ public static function window_error_for( ?string $from, ?string $to ): ?WP_Error { if ( null === $from && null === $to ) { return null; } if ( null === $from ) { return new WP_Error( 'saddle_analytics_bad_window', __( 'A “to” date needs a “from” date. Pass both or neither.', 'saddle-analytics' ), array( 'status' => 400 ) ); } if ( null !== $to && $to < $from ) { return new WP_Error( 'saddle_analytics_bad_window', __( '“from” must be on or before “to”.', 'saddle-analytics' ), array( 'status' => 400 ) ); } $today = gmdate( 'Y-m-d' ); if ( $from > $today || ( null !== $to && $to > $today ) ) { return new WP_Error( 'saddle_analytics_bad_window', __( 'Dates can’t be in the future.', 'saddle-analytics' ), array( 'status' => 400 ) ); } return null; } /** The visitor list: everyone, newest activity first. */ public function journeys( WP_REST_Request $r ) { return rest_ensure_response( $this->journey->all( (int) $r['limit'], (int) $r['offset'] ) ); } /** One visitor's session-grouped event stream. */ public function journey( WP_REST_Request $r ) { $id = (string) $r['visitor_id']; if ( ! Saddle_Analytics_Step_Visitor::valid_id( $id ) ) { return new WP_Error( 'saddle_analytics_bad_visitor', __( 'A visitor id is 32 hexadecimal characters.', 'saddle-analytics' ), array( 'status' => 400 ) ); } $data = $this->journey->one( $id ); if ( null === $data['visitor'] ) { return new WP_Error( 'saddle_analytics_no_visitor', __( 'No such visitor.', 'saddle-analytics' ), array( 'status' => 404 ) ); } return rest_ensure_response( $data ); } public function realtime() { return rest_ensure_response( $this->live->data() ); } /** Channel names as people read them, translated (the enum's English labels serve the CLI and logs). */ private static function channel_labels(): array { return array( Saddle_Analytics_Channel::DIRECT => __( 'Direct', 'saddle-analytics' ), Saddle_Analytics_Channel::AI => __( 'AI (referred)', 'saddle-analytics' ), Saddle_Analytics_Channel::ORGANIC => __( 'Organic search', 'saddle-analytics' ), Saddle_Analytics_Channel::SOCIAL => __( 'Social', 'saddle-analytics' ), Saddle_Analytics_Channel::PAID => __( 'Paid', 'saddle-analytics' ), Saddle_Analytics_Channel::EMAIL => __( 'Email', 'saddle-analytics' ), Saddle_Analytics_Channel::REFERRAL => __( 'Referral', 'saddle-analytics' ), ); } /** Static lookups the dashboard needs once: tabs and labels. */ public function meta() { $dimensions = array(); foreach ( Saddle_Analytics_Dimensions::all() as $d ) { $dimensions[] = array( 'key' => $d->key, 'label' => $d->label, 'group' => $d->group, 'format' => $d->format, ); } return rest_ensure_response( array( 'dimensions' => $dimensions, 'channels' => self::channel_labels(), 'channel_keys' => Saddle_Analytics_Channel::keys(), 'devices' => array( Saddle_Analytics_Device::DESKTOP => __( 'Desktop', 'saddle-analytics' ), Saddle_Analytics_Device::MOBILE => __( 'Mobile', 'saddle-analytics' ), Saddle_Analytics_Device::TABLET => __( 'Tablet', 'saddle-analytics' ), ), 'metrics' => Saddle_Analytics_Metric::all(), 'cookieless' => ! Saddle_Analytics_Identity::cookie_mode(), ) ); } }