$options Optional: `flow`, `step` (slug, e.g. `reminder1`), `phone`, * `message_id` (UUID), `coupon_code`, `product_id`, `ttl_days`. * @param array $context Ids the link is about, e.g. `cart_id`. */ public static function create( string $url, string $module, string $target, array $options = [], array $context = [] ): string { if ( ! self::enabled() || '' === $url ) { return $url; } // Same host as the shop, or no short link: the redirect must never be an // open redirect, and a link elsewhere is not the shop's to shorten. $host = wp_parse_url( home_url(), PHP_URL_HOST ); if ( ! is_string( $host ) || wp_parse_url( $url, PHP_URL_HOST ) !== $host ) { Logger::warning( 'links', 'Refused to shorten a link to another host.', [ 'module' => $module ] ); return $url; } $target = in_array( $target, self::TARGETS, true ) ? $target : 'page'; $phone = PhoneNumber::parse( (string) ( $options['phone'] ?? '' ) ); $messageId = (string) ( $options['message_id'] ?? '' ); $coupon = (string) ( $options['coupon_code'] ?? '' ); $productId = (int) ( $options['product_id'] ?? 0 ); $flow = sanitize_key( (string) ( $options['flow'] ?? '' ) ); $step = sanitize_key( (string) ( $options['step'] ?? '' ) ); $ttl = max( 1, (int) ( $options['ttl_days'] ?? self::TTL_DAYS ) ); $expires = time() + ( $ttl * DAY_IN_SECONDS ); $token = self::insert( $url, $module, $flow, $step, $target, $productId, $coupon, $phone, $messageId, $expires ); if ( null === $token ) { return $url; } if ( Events::listening() ) { $data = [ 'token' => $token, 'module' => sanitize_key( $module ), 'flow' => '' === $flow ? null : $flow, 'step' => '' === $step ? null : $step, 'message_id' => 1 === preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $messageId ) ? $messageId : null, 'target' => $target, 'product_id' => $productId > 0 ? (string) $productId : null, 'coupon_code' => '' === $coupon ? null : $coupon, 'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', $expires ), 'phone' => null === $phone ? null : $phone->canonical(), ]; $context = Message::context( $context ); if ( [] !== $context ) { $data['context'] = $context; } Events::emit( 'link.created', $data ); } return self::url( $token ); } /** * The public address of a token. * * @param string $token Link token. */ public static function url( string $token ): string { return home_url( '/' . self::PATH . '/' . $token ); } /** * Store a link under a fresh token. * * @param string $url Destination. * @param string $module Module slug. * @param string $flow Flow slug. * @param string $step Step slug. * @param string $target Destination kind. * @param int $productId Product, or 0. * @param string $coupon Coupon to apply, or empty. * @param PhoneNumber|null $phone Recipient. * @param string $messageId Message carrying the link. * @param int $expires Expiry timestamp. * * @return string|null The token, or null when the row could not be written. */ private static function insert( string $url, string $module, string $flow, string $step, string $target, int $productId, string $coupon, ?PhoneNumber $phone, string $messageId, int $expires ): ?string { global $wpdb; $table = Schema::table( 'links' ); // 62^8 tokens: guessing one is not a strategy. A collision is retried // rather than assumed impossible. for ( $try = 0; $try < 3; $try++ ) { $token = self::token(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table; the unique key decides. $written = $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (token, module, flow, step, target, destination, product_id, coupon_code, phone_hash, message_id, created_at, expires_at) VALUES (%s, %s, %s, %s, %s, %s, %d, %s, %s, %s, %s, %s)', $table, $token, sanitize_key( $module ), $flow, $step, $target, $url, $productId, $coupon, null === $phone ? '' : SiteKey::phoneHash( $phone->canonical() ), substr( $messageId, 0, 36 ), gmdate( 'Y-m-d H:i:s' ), gmdate( 'Y-m-d H:i:s', $expires ) ) ); if ( 1 === $written ) { self::maybePurge(); return $token; } } Logger::error( 'links', 'Could not store a short link.', [ 'module' => $module ] ); return null; } /** * Eight random characters from the 62-letter alphabet. */ private static function token(): string { $token = ''; for ( $i = 0; $i < 8; $i++ ) { $token .= self::ALPHABET[ random_int( 0, 61 ) ]; } return $token; } /** * Now and then, drop links long past their expiry. * * Trimmed as links are made rather than by a job of its own: a store that * sends nothing never grows the table. */ private static function maybePurge(): void { if ( 1 !== wp_rand( 1, 100 ) ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table, bounded delete. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at < %s LIMIT 1000', Schema::table( 'links' ), gmdate( 'Y-m-d H:i:s', time() - ( self::KEEP_DAYS * DAY_IN_SECONDS ) ) ) ); } }