sites = $sites; $this->connections = $connections; $this->access = $access; $this->cache = $cache; $this->dispatcher = $dispatcher; $this->email_log = $email_log; $this->lists = $lists; } /** * {@inheritDoc} * * @return array */ protected function actions() { return array( 'save_site' => 'save_site', 'delete_site' => 'delete_site', 'send_test' => 'send_test', ); } /** * Draft key for the current user (keeps the form across a validation error * or a Google sign-in). * * @return string */ public static function draft_key() { return 'turtlereport_site_draft_' . get_current_user_id(); } /** * Parks the form as typed, so leaving the page doesn't lose it. * * @param Profile $site Site built from the posted form. * @param int[] $user_ids Users granted access on the form. * @return void */ private function keep_draft( Profile $site, array $user_ids ) { \TurtleReport\Support\TransientStore::set( self::draft_key(), array( 'id' => $site->id, 'site' => $site->to_array(), 'users' => $user_ids, ), self::DRAFT_LIFETIME ); } /** * Sends the admin to Google to add another account for one data source. * * A new connection is always made rather than re-authorising an existing * one, because other sites may be reading through that one and signing it * in as somebody else would change their reports. The OAuth client (the * Cloud project's ID and secret) is reused from a connection already set * up, so there is nothing to paste and no second Cloud project to make. * * @param Profile $site Site being edited. * @param string $source Source key the account is for. * @param string $back Editor URL to return to. * @return void */ private function connect_account( Profile $site, $source, $back ) { $template = $this->oauth_template( $site, $source ); if ( ! $template ) { $this->finish( $back, 'error', __( 'Set up one Google sign-in connection first, on the Connections screen. After that, new Google accounts can be added from here without pasting anything.', 'turtlereport' ) ); } $secret = $this->connections->secret( $template->id, ConnectionRepository::SECRET_CLIENT_SECRET ); if ( '' === $secret ) { $this->finish( $back, 'error', __( 'The client secret for that Google connection could not be read. Open the Connections screen and add it again.', 'turtlereport' ) ); } $connection = $this->spare_connection( $template->client_id ); $connection->type = Connection::TYPE_OAUTH; $connection->client_id = $template->client_id; $connection->want_business_profile = Profile::SOURCE_BUSINESS === $source ? true : $template->want_business_profile; $connection = $this->connections->save( $connection ); $this->connections->set_secret( $connection->id, ConnectionRepository::SECRET_CLIENT_SECRET, $secret ); ConnectionActions::start_oauth( $connection, add_query_arg( array( 'connected' => $connection->id, 'source' => $source, ), $back ) ); } /** * The connection whose OAuth client a new account should reuse. * * Prefers the one this source already reads through, then the site's main * one, then any finished Google sign-in connection. * * @param Profile $site Site being edited. * @param string $source Source key. * @return Connection|null */ private function oauth_template( Profile $site, $source ) { $ids = array( $site->connection_for( $source ), $site->connection_id ); foreach ( $ids as $id ) { $candidate = '' !== $id ? $this->connections->find( $id ) : null; if ( $candidate && Connection::TYPE_OAUTH === $candidate->type && '' !== $candidate->client_id ) { return $candidate; } } foreach ( $this->connections->all() as $candidate ) { if ( Connection::TYPE_OAUTH === $candidate->type && '' !== $candidate->client_id && $candidate->is_connected() ) { return $candidate; } } return null; } /** * A connection record to authorise into. * * Reuses an unfinished one for the same OAuth client when there is one, so * abandoning Google's consent screen a few times doesn't leave a trail of * half-made connections. Finished connections are never touched. * * @param string $client_id OAuth client ID. * @return Connection */ private function spare_connection( $client_id ) { foreach ( $this->connections->all() as $candidate ) { if ( Connection::TYPE_OAUTH === $candidate->type && $candidate->client_id === $client_id && ! $candidate->is_connected() && ! $this->sites->using_connection( $candidate->id ) ) { return $candidate; } } return new Connection(); } /** * Saves a site: sources, collection settings, segments, emails and access. * * Every field is read first; if something is wrong, the form comes back * with what the admin typed instead of losing it. * * @return void */ public function save_site() { $this->guard( 'save_site' ); $id = sanitize_key( $this->text( 'site' ) ); $site = '' !== $id ? $this->sites->find( $id ) : new Profile(); if ( ! $site ) { $this->finish( SitesPage::url(), 'error', __( 'Site not found.', 'turtlereport' ) ); } $back = '' !== $site->id ? SitesPage::url( array( 'action' => 'edit', 'site' => $site->id, ) ) : SitesPage::url( array( 'action' => 'new' ) ); $gsc = $this->text( 'gsc_site' ); $search_type = $this->text( 'search_type' ); $site->connection_id = sanitize_key( $this->text( 'connection_id' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in guard(). $own = isset( $_POST['source_connections'] ) && is_array( $_POST['source_connections'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['source_connections'] ) ) : array(); foreach ( Profile::sources() as $source ) { $chosen = isset( $own[ $source ] ) ? $own[ $source ] : ''; // "Same as the main connection" is stored as empty, so changing the main one moves this source too. $site->source_connections[ $source ] = '' !== $chosen && $chosen !== $site->connection_id && $this->connections->find( $chosen ) ? $chosen : ''; } $site->gsc_site = $gsc; $site->search_type = in_array( $search_type, Profile::SEARCH_TYPES, true ) ? $search_type : 'web'; $site->name = Text::substr( $this->text( 'name' ), 0, 80 ); $site->ga_property = $this->match( 'ga_property', '#^properties/\d+$#' ); $site->ga_property_name = '' !== $site->ga_property ? $this->label( $site, SourceLists::ANALYTICS, $site->ga_property, 'ga_property_name' ) : ''; $site->gbp_location = $this->match( 'gbp_location', '#^locations/\d+$#' ); $site->gbp_location_name = '' !== $site->gbp_location ? $this->label( $site, SourceLists::BUSINESS_PROFILE, $site->gbp_location, 'gbp_location_name' ) : ''; $site->lead_basis = Profile::BASIS_SESSION === $this->text( 'lead_basis' ) ? Profile::BASIS_SESSION : Profile::BASIS_FIRST_USER; $site->conversion_label = Text::substr( $this->text( 'conversion_label' ), 0, self::MAX_LABEL_LENGTH ); $site->traffic_scope = Profile::SCOPE_ALL === $this->text( 'traffic_scope' ) ? Profile::SCOPE_ALL : Profile::SCOPE_ORGANIC; $logo = absint( $this->text( 'client_logo_id' ) ); $site->client_logo_id = $logo && wp_attachment_is_image( $logo ) ? $logo : 0; // phpcs:disable WordPress.Security.NonceVerification.Missing -- Checked in guard(). $sections = isset( $_POST['sections'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['sections'] ) ) : array(); $metrics = isset( $_POST['traffic_metrics'] ) ? array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['traffic_metrics'] ) ) : array(); $groups = isset( $_POST['groups'] ) && is_array( $_POST['groups'] ) ? wp_unslash( $_POST['groups'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised in conversion_groups(). $segments = isset( $_POST['segments'] ) && is_array( $_POST['segments'] ) ? wp_unslash( $_POST['segments'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised in Segments::normalize(). $email = isset( $_POST['email'] ) && is_array( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised in Schedule::normalize(). $delivery = isset( $_POST['delivery'] ) && is_array( $_POST['delivery'] ) ? wp_unslash( $_POST['delivery'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised in Options::from_form(). $roles = isset( $_POST['roles'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['roles'] ) ) : array(); $user_ids = isset( $_POST['users'] ) ? array_map( 'absint', array_filter( (array) wp_unslash( $_POST['users'] ), 'is_scalar' ) ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nested values are discarded before each scalar is sanitized with absint(). $prio = isset( $_POST['priorities'] ) && is_array( $_POST['priorities'] ) ? wp_unslash( $_POST['priorities'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised in priorities(). // phpcs:enable $site->sections = array_values( array_intersect( Profile::all_sections(), $sections ) ); $site->traffic_metrics = array_slice( array_values( array_intersect( array_keys( TrafficMetrics::all() ), $metrics ) ), 0, Profile::MAX_TRAFFIC_METRICS ); $site->conversion_groups = $this->conversion_groups( $groups ); $site->segments = Segments::normalize( $segments ); $site->priorities = $this->priorities( $prio, $site ); $before = $site->email; $site->email = Schedule::normalize( $email ); $site->roles = array_values( array_intersect( $roles, array_keys( $this->access->grantable_roles() ) ) ); $site->delivery = Options::with_link_key( Options::from_form( $delivery, $site->delivery ), ! empty( $site->delivery['share_link'] ), ! empty( $delivery['revoke_links'] ) ); // "Connect Google account" under a source: park the half-filled form and go to Google. $connect = $this->text( 'connect_source' ); if ( '' !== $connect && in_array( $connect, Profile::sources(), true ) ) { $this->keep_draft( $site, $user_ids ); $this->connect_account( $site, $connect, $back ); } $error = $this->validate( $site, $segments, $email ); if ( '' !== $error ) { $this->keep_draft( $site, $user_ids ); $this->finish( $back, 'error', $error ); } $is_new = '' === $site->id; $site = $this->sites->save( $site ); $this->access->set_users_for_site( $site->id, $user_ids ); $this->cache->flush( ServicesFactory::site_group( $site->id ) ); // Only a change to when (or whether) it sends moves the next email; editing the note or recipients doesn't. if ( $is_new || self::timing( $before ) !== self::timing( $site->email ) || Schedule::is_active( $before ) !== Schedule::is_active( $site->email ) || ! $this->email_log->get( $site->id )['next'] ) { $this->dispatcher()->reschedule( $site ); } delete_transient( self::draft_key() ); $overlap = $site->overlapping_events(); if ( $overlap ) { Notices::add( 'warning', sprintf( /* translators: %s: comma-separated GA4 event names. */ __( 'These GA4 events are in more than one conversion group: %s. Each group counts them, but the total counts each event once.', 'turtlereport' ), implode( ', ', array_keys( $overlap ) ) ) ); } $this->finish( SitesPage::url(), 'success', $is_new /* translators: %s: site name. */ ? sprintf( __( 'Site "%s" added.', 'turtlereport' ), $site->display_name() ) /* translators: %s: site name. */ : sprintf( __( 'Site "%s" saved.', 'turtlereport' ), $site->display_name() ) ); } /** * Goals and priorities from the form. Priority events must be among the * site's own conversion events (just saved above), so nothing is tracked twice. * * @param array $raw Posted priorities. * @param Profile $site Site with its new conversion groups. * @return array */ private function priorities( array $raw, Profile $site ) { $clean = array( 'goal' => isset( $raw['goal'] ) && is_string( $raw['goal'] ) ? sanitize_key( $raw['goal'] ) : '', 'custom' => isset( $raw['custom'] ) && is_string( $raw['custom'] ) ? sanitize_text_field( $raw['custom'] ) : '', 'pages' => isset( $raw['pages'] ) && is_array( $raw['pages'] ) ? array_map( 'sanitize_text_field', array_filter( $raw['pages'], 'is_string' ) ) : array(), 'events' => isset( $raw['events'] ) && is_array( $raw['events'] ) ? array_map( 'sanitize_text_field', array_filter( $raw['events'], 'is_string' ) ) : array(), ); $p = Priorities::normalize( $clean ); $p['events'] = array_values( array_intersect( $p['events'], $site->all_conversion_events() ) ); return $p; } /** * Checks a site before saving. * * @param Profile $site Site with posted values. * @param array $segments Raw posted segments. * @param array $email Raw posted email settings. * @return string Error message, or an empty string when valid. */ private function validate( Profile $site, array $segments, array $email ) { if ( ! $this->connections->find( $site->connection_id ) ) { return __( 'Choose a Google connection for this site.', 'turtlereport' ); } $gbp = $this->connections->find( $site->connection_for( Profile::SOURCE_BUSINESS ) ); if ( '' !== $site->gbp_location && $gbp && Connection::TYPE_SERVICE_ACCOUNT === $gbp->type ) { return __( 'Business Profile can only be read through a Google sign-in connection. Choose one under "Google account" next to Business Profile.', 'turtlereport' ); } if ( '' !== $site->gsc_site && ( ! preg_match( '#^(sc-domain:[a-z0-9.-]+|https?://\S+)$#i', $site->gsc_site ) || strlen( $site->gsc_site ) > 255 ) ) { return __( 'That Search Console property isn\'t valid. Choose it from the list.', 'turtlereport' ); } if ( ! array_filter( Profile::all_sections(), array( $site, 'wants' ) ) ) { return __( 'Nothing to report yet: switch on at least one section and choose its data source (a Search Console property, GA4 property or Business Profile location).', 'turtlereport' ); } $filled = array_filter( $segments, static function ( $seg ) { return is_array( $seg ) && isset( $seg['name'], $seg['value'] ) && is_scalar( $seg['name'] ) && is_scalar( $seg['value'] ) && '' !== trim( (string) $seg['name'] ) && '' !== trim( (string) $seg['value'] ); } ); if ( count( $filled ) > count( $site->segments ) && count( $site->segments ) < Segments::MAX_SEGMENTS ) { return __( 'One of the segments has a regular expression Google can\'t use. Look-arounds and back-references aren\'t supported.', 'turtlereport' ); } if ( ! empty( $email['enabled'] ) && ! Schedule::has_audience( $site->email ) ) { return __( 'Choose who gets the scheduled email: everyone who can view this site, specific people, or other email addresses. Or switch scheduled emails off.', 'turtlereport' ); } return ''; } /** * Sends a test email of a saved site's report to the current user, one of * its scheduled recipients, or all of them. * * @return void */ public function send_test() { $this->guard( 'send_test' ); $site = $this->sites->find( sanitize_key( $this->text( 'site' ) ) ); if ( ! $site ) { $this->finish( SitesPage::url(), 'error', __( 'Site not found.', 'turtlereport' ) ); } $back = SitesPage::url( array( 'action' => 'edit', 'site' => $site->id, ) ); // Only "me" or people the saved schedule already sends to: the form can't be used to mail anyone else. $choice = $this->text( 'test_to' ); $audience = ( new Recipients( $this->access ) )->addresses( $site ); if ( 'all' === $choice ) { $to = $audience; } elseif ( '' !== $choice && 'me' !== $choice && in_array( strtolower( $choice ), array_map( 'strtolower', $audience ), true ) ) { $to = array( $choice ); } else { $to = array( wp_get_current_user()->user_email ); } if ( ! $to ) { $this->finish( $back, 'error', __( 'Nobody is on this site\'s email list yet. Choose recipients and save first.', 'turtlereport' ) ); } $sent = $this->dispatcher()->send_test( $site, $to ); if ( is_wp_error( $sent ) ) { $this->finish( $back, 'error', $sent->get_error_message() ); } $count = count( $to ); $this->finish( $back, 'success', 1 === $count /* translators: %s: email address. */ ? sprintf( __( 'Test report for %s accepted by the mail system. It\'s listed under Email deliveries.', 'turtlereport' ), $to[0] ) /* translators: %d: number of recipients. */ : sprintf( _n( 'Test report accepted by the mail system for %d person. It\'s listed under Email deliveries.', 'Test report accepted by the mail system for all %d people. It\'s listed under Email deliveries.', $count, 'turtlereport' ), $count ) ); } /** * Deletes a site and every access grant for it. * * @return void */ public function delete_site() { $this->guard( 'delete_site' ); $site = $this->sites->find( sanitize_key( $this->text( 'site' ) ) ); if ( ! $site ) { $this->finish( SitesPage::url(), 'error', __( 'Site not found.', 'turtlereport' ) ); } $this->access->forget_site( $site->id ); $this->cache->flush( ServicesFactory::site_group( $site->id ) ); $this->dispatcher()->forget( $site->id ); $this->email_log->forget( $site->id ); if ( \TurtleReport\Delivery\Schema::ready() || \TurtleReport\Delivery\Schema::exists() ) { $this->dispatcher()->runs()->forget_site( $site->id ); } $this->sites->delete( $site->id ); /* translators: %s: site name. */ $this->finish( SitesPage::url(), 'success', sprintf( __( 'Site "%s" deleted. Nobody can see its report any more, and its scheduled emails stop.', 'turtlereport' ), $site->display_name() ) ); } /** * The parts of a schedule that decide when it sends. * * @param array $email Normalised schedule. * @return array */ private static function timing( array $email ) { return array_intersect_key( $email, array_flip( array( 'enabled', 'frequency', 'weekday', 'monthday', 'hour' ) ) ); } /** * The email dispatcher. * * @return Dispatcher */ private function dispatcher() { return call_user_func( $this->dispatcher ); } /** * Name of a chosen GA4 property or location, from Google's list (cached for * ten minutes), so it's right even if the editor script didn't run. * Falls back to the label the form sent. * * @param Profile $site Site being saved. * @param string $source SourceLists source slug. * @param string $value Chosen value. * @param string $field Form field with the browser's label. * @return string */ private function label( Profile $site, $source, $value, $field ) { $map = SitesPage::source_map(); $connection = $this->lists ? $this->connections->find( $site->connection_for( $map[ $source ] ) ) : null; if ( $connection && '' === SourceLists::unavailable( $connection, $source ) ) { try { foreach ( $this->lists->items( $connection, $source ) as $item ) { if ( $item['value'] === $value ) { return Text::substr( (string) $item['label'], 0, 200 ); } } } catch ( ApiException $e ) { unset( $e ); // Google unreachable: keep the browser's label. } } return Text::substr( $this->text( $field ), 0, 200 ); } /** * Reads a field that must match a pattern, or returns an empty string. * * @param string $name Field name. * @param string $pattern Regular expression. * @return string */ private function match( $name, $pattern ) { $value = $this->text( $name ); return preg_match( $pattern, $value ) ? $value : ''; } /** * Cleans posted conversion groups. Groups without a label are dropped. * * @param array $raw Posted groups, each with label and comma-separated events. * @return array[] */ private function conversion_groups( array $raw ) { $out = array(); foreach ( $raw as $group ) { if ( ! is_array( $group ) ) { continue; } $label = isset( $group['label'] ) && is_scalar( $group['label'] ) ? trim( sanitize_text_field( (string) $group['label'] ) ) : ''; if ( '' === $label ) { continue; } $out[] = array( 'label' => Text::substr( $label, 0, self::MAX_LABEL_LENGTH ), 'events' => $this->event_list( isset( $group['events'] ) && is_string( $group['events'] ) ? sanitize_text_field( $group['events'] ) : '' ), ); if ( count( $out ) >= Profile::MAX_GROUPS ) { break; } } return $out; } /** * Splits a comma-separated list of GA4 event names. * * @param string $raw Raw input. * @return string[] */ private function event_list( $raw ) { $out = array(); foreach ( explode( ',', $raw ) as $name ) { $name = trim( $name ); if ( '' !== $name && strlen( $name ) <= self::MAX_EVENT_LENGTH ) { $out[] = $name; } } return array_slice( array_values( array_unique( $out ) ), 0, self::MAX_EVENTS ); } }