[a-z0-9]{2,20})'; /** Longest date range a report may request, in days (Search Console keeps 16 months). */ const MAX_RANGE_DAYS = 550; /** Minimum seconds between manual refreshes of one site. */ const REFRESH_COOLDOWN = 60; /** * Reports to expose. * * @var Report[] */ private $reports; /** * Site storage. * * @var ProfileRepository */ private $sites; /** * Response cache. * * @var Cache */ private $cache; /** * Connection health monitor, told when a connection stops working. * * @var HealthMonitor|null */ private $health; /** * Constructor. * * @param Report[] $reports Reports. * @param ProfileRepository $sites Site storage. * @param Cache $cache Response cache. * @param HealthMonitor|null $health Connection health monitor. */ public function __construct( array $reports, ProfileRepository $sites, Cache $cache, ?HealthMonitor $health = null ) { $this->reports = $reports; $this->sites = $sites; $this->cache = $cache; $this->health = $health; } /** * Registers routes. * * @return void */ public function register_routes() { $date = array( 'type' => 'string', 'pattern' => '^\d{4}-\d{2}-\d{2}$', 'required' => true, ); foreach ( $this->reports as $report ) { register_rest_route( self::NAMESPACE_V1, '/sites/' . self::SITE_PATTERN . '/reports/' . $report->id(), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => function ( \WP_REST_Request $request ) use ( $report ) { return $this->run( $report, $request ); }, 'permission_callback' => array( $this, 'can_view_site' ), 'args' => array_merge( array( 'start' => $date, 'end' => $date, 'segment' => array( 'type' => 'string', 'pattern' => '^(!?g[a-z0-9]{4,12})?$', 'default' => '', ), ), $report->args() ), ) ); } register_rest_route( self::NAMESPACE_V1, '/sites/' . self::SITE_PATTERN . '/cache', array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'refresh' ), 'permission_callback' => array( $this, 'can_view_site' ), ) ); } /** * Permission check: the current user must be allowed to view the site. * * Unknown sites fail the same way as forbidden ones, so the API doesn't * reveal which site IDs exist. * * @param \WP_REST_Request $request Request. * @return bool */ public function can_view_site( \WP_REST_Request $request ) { return current_user_can( Capabilities::VIEW_SITE, (string) $request['site'] ); } /** * Runs a report. * * @param Report $report Report. * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error * @throws \TurtleReport\Auth\ApiException Caught here and returned as a WP_Error. */ private function run( Report $report, \WP_REST_Request $request ) { $site = $this->sites->find( (string) $request['site'] ); if ( ! $site ) { return new \WP_Error( 'turtlereport_not_found', __( 'This site no longer exists. Reload the page.', 'turtlereport' ), array( 'status' => 404 ) ); } $range = $this->validate_range( (string) $request['start'], (string) $request['end'] ); if ( is_wp_error( $range ) ) { return $range; } // The comparison period gets the same checks when a report takes one. if ( '' !== (string) $request['prev_start'] || '' !== (string) $request['prev_end'] ) { $prev = $this->validate_range( (string) $request['prev_start'], (string) $request['prev_end'] ); if ( is_wp_error( $prev ) ) { return $prev; } } $params = array(); foreach ( array_keys( array_merge( array( 'start' => 1, 'end' => 1, 'segment' => 1, ), $report->args() ) ) as $key ) { $params[ $key ] = $request[ $key ]; } try { if ( ! $site->wants( $report->section() ) ) { throw new ApiException( __( 'This part of the report is switched off for this site.', 'turtlereport' ), ApiException::DISABLED ); } $data = $report->run( $site, $params ); } catch ( ApiException $e ) { if ( ApiException::RECONNECT === $e->reason() && $this->health ) { $this->health->mark_failed( $site->connection_for( $report->source() ), $e->getMessage() ); } return $e->to_wp_error(); } $response = rest_ensure_response( $data ); $response->header( 'Cache-Control', 'private, no-store' ); return $response; } /** * Clears a site's cached Google data. * * Rate-limited per site so repeated clicks can't burn through Google quota. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function refresh( \WP_REST_Request $request ) { $site_id = (string) $request['site']; $lock = 'turtlereport_refresh_' . $site_id; if ( get_transient( $lock ) ) { return rest_ensure_response( array( 'refreshed' => false, 'message' => __( 'This report was refreshed less than a minute ago.', 'turtlereport' ), ) ); } \TurtleReport\Support\TransientStore::set( $lock, 1, self::REFRESH_COOLDOWN ); $this->cache->flush( ServicesFactory::site_group( $site_id ) ); return rest_ensure_response( array( 'refreshed' => true ) ); } /** * Checks a date range. * * @param string $start Start date. * @param string $end End date. * @return true|\WP_Error */ private function validate_range( $start, $end ) { $s = \DateTimeImmutable::createFromFormat( '!Y-m-d', $start, new \DateTimeZone( 'UTC' ) ); $e = \DateTimeImmutable::createFromFormat( '!Y-m-d', $end, new \DateTimeZone( 'UTC' ) ); if ( ! $s || ! $e || $s->format( 'Y-m-d' ) !== $start || $e->format( 'Y-m-d' ) !== $end || $s > $e ) { return new \WP_Error( 'turtlereport_bad_range', __( 'That date range isn\'t valid. Choose a start and an end date, with the start on or before the end.', 'turtlereport' ), array( 'status' => 400 ) ); } if ( \TurtleReport\Support\Periods::inclusive_days( $start, $end ) > self::MAX_RANGE_DAYS ) { return new \WP_Error( 'turtlereport_bad_range', sprintf( /* translators: %d: maximum number of days in a date range. */ __( 'That date range is too long. Choose %d days or fewer.', 'turtlereport' ), self::MAX_RANGE_DAYS ), array( 'status' => 400 ) ); } return true; } }