getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } /** * Whether a source form's submissions go to LaunchList right now: the site is connected (or * revoked, when they are kept for a reconnect), it is not a copy, and the form has an enabled * route. Adapters ask before writing anything of their own, so forms that are not sent are left * untouched. * * @param string $source The adapter's slug. * @param string $form_id The form's ID in its plugin. * @return bool */ public static function routed( $source, $form_id ) { try { if ( ! Connection::is_connected() || Connection::is_copy() ) { return false; } $route = Routes::get( Routes::key( (string) $source, (string) $form_id ) ); return null !== $route && $route['enabled']; } catch ( \Throwable $e ) { return false; } } /** * The work of {@see Relay::capture()}. * * @param string $source The adapter's slug. * @param string $form_id The form's ID in its plugin. * @param string $form_title The form's title in its plugin. * @param array $values The values. * @return void */ private static function handle( $source, $form_id, $form_title, array $values ) { // A copy of a connected site (staging) never sends in that site's name. if ( ! self::routed( $source, $form_id ) ) { return; } $route = Routes::get( Routes::key( $source, $form_id ) ); $payload = self::payload( $source, $form_title, $route, $values, Attribution::current(), self::identity( $route ) ); if ( empty( $payload['data'] ) ) { Activity::record( $payload, Activity::SKIPPED, 'empty' ); return; } // Revoked: keep it for a reconnect, within the queue's 24 hours. if ( ! Connection::is_active() ) { self::queue( $payload, 'revoked', 0, 0, MINUTE_IN_SECONDS ); return; } self::deliver( $payload ); } /** * The submission body. * * @param string $source The adapter's slug. * @param string $form_title The form's title in its plugin. * @param array $route The route (`form`, `map`). * @param array $values The values. * @param array $attribution From {@see Attribution}. * @param array $identity `{key, type}` of the LaunchList form's identity field. * @param string|null $id The delivery ID; a fresh UUID when null. * @return array{id: string, form: string, source: string, source_form: string, data: array, attribution?: array} */ public static function payload( $source, $form_title, array $route, array $values, array $attribution, array $identity, $id = null ) { $payload = array( 'id' => null === $id ? wp_generate_uuid4() : (string) $id, 'form' => (string) $route['form'], 'source' => (string) $source, 'source_form' => Connection::truncate( trim( sanitize_text_field( $form_title ) ), self::MAX_TITLE ), 'data' => self::data( isset( $route['map'] ) && is_array( $route['map'] ) ? $route['map'] : array(), $values, $identity ), ); if ( ! empty( $attribution ) ) { $payload['attribution'] = $attribution; } return $payload; } /** * The `data` of a submission, matched fields first: * * 1. every mapped field under its LaunchList key; * 2. when nothing filled the identity field, the first value of the identity's type (an email * field for an email identity) under the identity key; * 3. up to 20 unmatched fields, each under a key LaunchList keeps as input data * ({@see Relay::input_key()}): from the label, else the field's name, else `Field N`, with * ` 2`, ` 3` … added when it would repeat a key already used (ignoring case), any LaunchList * field key of the route, or a key LaunchList reserves. * * Empty values are skipped, lists are joined with `, `, values are cut to 2,000 characters, and * no more than 50 fields are sent. * * @param array $map LaunchList key => source field name (`""`: not sent). * @param array $values The values. * @param array $identity `{key, type}`. * @return array */ public static function data( array $map, array $values, array $identity ) { $fields = array(); $position = 0; foreach ( $values as $item ) { if ( ! is_array( $item ) || ! isset( $item['name'] ) || ! ( is_string( $item['name'] ) || is_int( $item['name'] ) ) ) { continue; } $name = (string) $item['name']; if ( '' === $name || isset( $fields[ $name ] ) ) { continue; } $fields[ $name ] = array( 'position' => ++$position, 'label' => isset( $item['label'] ) && is_string( $item['label'] ) ? $item['label'] : '', 'type' => isset( $item['type'] ) && is_string( $item['type'] ) ? strtolower( $item['type'] ) : 'other', 'value' => self::value( isset( $item['value'] ) ? $item['value'] : '' ), ); } $data = array(); $used = array(); foreach ( $map as $key => $source_name ) { $source_name = (string) $source_name; $key = (string) $key; if ( '' === $key || '' === $source_name || ! isset( $fields[ $source_name ] ) ) { continue; } $used[ $source_name ] = true; if ( '' !== $fields[ $source_name ]['value'] && count( $data ) < self::MAX_FIELDS ) { $data[ $key ] = $fields[ $source_name ]['value']; } } $identity_key = isset( $identity['key'] ) && '' !== (string) $identity['key'] ? (string) $identity['key'] : 'email'; if ( ! self::has_key( $data, $identity_key ) ) { $types = self::fallback_types( isset( $identity['type'] ) ? (string) $identity['type'] : '' ); foreach ( $fields as $name => $field ) { if ( isset( $used[ $name ] ) || '' === $field['value'] || ! in_array( $field['type'], $types, true ) ) { continue; } $data[ $identity_key ] = $field['value']; $used[ $name ] = true; break; } } // Keys an unmatched field may not take: LaunchList's reserved keys, every LaunchList field key // of the route (sent or deliberately not), the identity key, and every key already used. $taken = array(); foreach ( array_merge( self::RESERVED_KEYS, array_map( 'strval', array_keys( $map ) ), array( $identity_key ), array_map( 'strval', array_keys( $data ) ) ) as $key ) { $taken[ self::lower( $key ) ] = true; } $unmatched = 0; foreach ( $fields as $name => $field ) { if ( isset( $used[ $name ] ) || '' === $field['value'] ) { continue; } if ( $unmatched >= self::MAX_UNMATCHED || count( $data ) >= self::MAX_FIELDS ) { break; } $key = self::input_key( $field['label'] ); $key = '' === $key ? self::input_key( $name ) : $key; $key = '' === $key ? 'Field ' . $field['position'] : $key; $key = self::unique_key( $key, $taken ); $taken[ self::lower( $key ) ] = true; $data[ $key ] = $field['value']; ++$unmatched; } return $data; } /** * A key LaunchList keeps as input data, made from a label or a name: accents removed, * apostrophes dropped, any other character but letters, digits, spaces, `_` and `-` turned into a * space, spaces collapsed, trimmed, starting with a letter or digit, at most 64 characters. `""` * when nothing is left. * * @param string $text The label or name. * @return string */ public static function input_key( $text ) { $text = wp_strip_all_tags( (string) $text ); $text = str_replace( array( "'", "\u{2019}", "\u{2018}", '`', "\u{00B4}" ), '', $text ); $text = remove_accents( $text ); $text = (string) preg_replace( '/[^A-Za-z0-9 _\-]+/', ' ', $text ); $text = (string) preg_replace( '/ {2,}/', ' ', $text ); $text = ltrim( trim( $text ), ' _-' ); // A key of digits alone becomes a number once the JSON is decoded, and LaunchList keeps only // named keys, so "2024" is sent as "Field 2024". if ( preg_match( '/^[0-9]+$/D', $text ) ) { $text = 'Field ' . $text; } return rtrim( substr( $text, 0, self::MAX_INPUT_KEY ) ); } /** * A key not yet taken (ignoring case): the key itself, or the key with ` 2`, ` 3` … within 64 * characters. * * @param string $key The key. * @param array $taken Lowercased keys taken. * @return string */ private static function unique_key( $key, array $taken ) { $candidate = $key; $number = 2; while ( isset( $taken[ self::lower( $candidate ) ] ) ) { $suffix = ' ' . $number; $candidate = rtrim( substr( $key, 0, self::MAX_INPUT_KEY - strlen( $suffix ) ) ) . $suffix; ++$number; } return $candidate; } /** * One value as a string: a list joined with `, `, a scalar cast, invalid UTF-8 removed, trimmed, * and cut to 2,000 characters. * * @param mixed $value The value. * @return string */ public static function value( $value ) { if ( is_array( $value ) ) { $parts = array(); array_walk_recursive( $value, static function ( $item ) use ( &$parts ) { $item = Relay::scalar( $item ); if ( '' !== $item ) { $parts[] = $item; } } ); $value = implode( ', ', $parts ); } else { $value = self::scalar( $value ); } return Connection::truncate( trim( wp_check_invalid_utf8( $value, true ) ), self::MAX_VALUE ); } /** * A scalar as a trimmed string; anything else as `""`. * * @param mixed $value The value. * @return string */ public static function scalar( $value ) { if ( is_scalar( $value ) ) { return trim( (string) $value ); } if ( is_object( $value ) && method_exists( $value, '__toString' ) ) { return trim( (string) $value ); } return ''; } /** * Send a submission once; queue it when LaunchList cannot take it yet; record the outcome. * * @param array $payload The submission. * @return void */ public static function deliver( array $payload ) { $hold = self::held( (string) $payload['form'] ); if ( null !== $hold ) { self::queue( $payload, 'held', 0, 0, max( 0, $hold['until'] - time() ) ); return; } $result = Client::submit( $payload ); if ( $result->ok() ) { Activity::record( $payload, Activity::SENT, '', $result->status ); self::received(); return; } if ( $result->revoked() ) { self::queue( $payload, 'revoked', $result->status, 0, MINUTE_IN_SECONDS ); return; } if ( $result->retryable ) { self::hold( $result, (string) $payload['form'] ); self::queue( $payload, $result->reason, $result->status, 1, Queue::backoff( 1, $result->retry_after ) ); return; } Activity::record( $payload, Activity::REFUSED, $result->reason, $result->status ); } /** * Hold new sends after a retryable failure: one LaunchList form when it is about that form (429, * 503 `paused`), every form otherwise. A 409 is about one submission and holds nothing. * * @param Result $result The answer. * @param string $form The LaunchList form ID. * @return array{until: int, reason: string, status: int}|null The hold, if one was set. */ public static function hold( Result $result, $form ) { if ( ! $result->retryable || 409 === $result->status ) { return null; } $wait = null === $result->retry_after ? self::HOLD_SECONDS : max( 30, min( 15 * MINUTE_IN_SECONDS, $result->retry_after ) ); $hold = array( 'until' => time() + $wait, 'reason' => $result->reason, 'status' => $result->status, ); set_transient( $result->form_wide() ? self::FORM_HOLD . $form : self::HOLD, $hold, $wait ); return $hold; } /** * The hold on every form, if any. * * @return array{until: int, reason: string, status: int}|null */ public static function site_hold() { return self::read_hold( self::HOLD ); } /** * The hold on one LaunchList form, if any. * * @param string $form The LaunchList form ID. * @return array{until: int, reason: string, status: int}|null */ public static function form_hold( $form ) { return self::read_hold( self::FORM_HOLD . $form ); } /** * The hold that applies to a LaunchList form: on every form, else on that one. * * @param string $form The LaunchList form ID. * @return array{until: int, reason: string, status: int}|null */ public static function held( $form ) { $hold = self::site_hold(); return null !== $hold ? $hold : self::form_hold( $form ); } /** * Note that LaunchList accepted a signup from this site's own forms. * * @return void */ public static function received() { $credentials = Connection::credentials(); if ( null !== $credentials ) { Connection::mark_receiving( $credentials ); } } /** * The identity field of a route's LaunchList form: from the cached forms list, else as saved * with the route, else `email`. Never asks LaunchList: a visitor is waiting. * * @param array $route The route. * @return array{key: string, type: string} */ public static function identity( array $route ) { $form = Forms::find( Forms::cached(), (string) $route['form'] ); if ( null !== $form ) { return Forms::identity( $form ); } if ( ! empty( $route['identity'] ) ) { return array( 'key' => (string) $route['identity'], 'type' => isset( $route['identity_type'] ) ? (string) $route['identity_type'] : '', ); } return array( 'key' => 'email', 'type' => 'email', ); } /** * Queue a submission and record that it waits, or why it could not be kept. * * @param array $payload The submission. * @param string $reason Why. * @param int $status HTTP status. * @param int $attempts Tries made. * @param int $delay Seconds to the next try. * @return void */ private static function queue( array $payload, $reason, $status, $attempts, $delay ) { $code = 0 === (int) $status ? (string) $reason : 'http_' . (int) $status . ( '' === $reason ? '' : ':' . $reason ); $stored = Queue::push( $payload, $code, $delay, $attempts ); if ( Queue::STORED === $stored ) { Activity::record( $payload, Activity::QUEUED, $reason, $status ); } elseif ( Queue::FULL === $stored ) { Activity::record( $payload, Activity::FAILED, 'queue_full', $status ); } else { Activity::record( $payload, Activity::FAILED, 'storage', $status ); } } /** * A hold, while it lasts. * * @param string $key Transient key. * @return array{until: int, reason: string, status: int}|null */ private static function read_hold( $key ) { $hold = get_transient( $key ); if ( ! is_array( $hold ) || ! isset( $hold['until'] ) || (int) $hold['until'] <= time() ) { return null; } return array( 'until' => (int) $hold['until'], 'reason' => isset( $hold['reason'] ) ? (string) $hold['reason'] : '', 'status' => isset( $hold['status'] ) ? (int) $hold['status'] : 0, ); } /** * Whether `data` has a key, ignoring case (as LaunchList matches keys). * * @param array $data The data. * @param string $key The key. * @return bool */ private static function has_key( array $data, $key ) { foreach ( array_keys( $data ) as $existing ) { if ( self::lower( (string) $existing ) === self::lower( $key ) ) { return true; } } return false; } /** * Source field types that can stand in for an identity of a type. * * @param string $type The LaunchList identity field's type. * @return array */ private static function fallback_types( $type ) { $type = strtolower( $type ); if ( 'phone' === $type || 'tel' === $type ) { return array( 'tel' ); } if ( '' === $type || 'email' === $type ) { return array( 'email' ); } return array(); } /** * Lowercase, multibyte-aware. * * @param string $value The value. * @return string */ private static function lower( $value ) { return function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $value, 'UTF-8' ) : strtolower( (string) $value ); } }