success_callback_url); if ($callbackUrl === '') { throw new InvalidArgumentException('Callback URL is missing.'); } if (! filter_var($callbackUrl, FILTER_VALIDATE_URL)) { throw new InvalidArgumentException('Callback URL is invalid.'); } $parts = parse_url($callbackUrl); if (! is_array($parts) || ! in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true) || trim((string) ($parts['host'] ?? '')) === '') { throw new InvalidArgumentException('Callback URL must use HTTP or HTTPS.'); } if ($application->submitted_at === null) { throw new InvalidArgumentException('Application submission timestamp is missing.'); } $parameters = self::parameters($application, $channel); $query = self::mergeQuery((string) ($parts['query'] ?? ''), $parameters); unset($parts['query']); $fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : ''; unset($parts['fragment']); return self::assembleUrl($parts).'?'.$query.$fragment; } public static function buildMiniProgramPath( Application $application, SignupChannel $channel, bool $appendParameters = true ): string { $path = trim((string) $channel->mini_program_callback_path); if ($path === '' || ! preg_match('/^\/(?!\/)[^#]*$/', $path)) { throw new InvalidArgumentException('Mini program callback path is invalid.'); } [$basePath, $existingQuery] = array_pad(explode('?', $path, 2), 2, ''); if (! $appendParameters) { return $basePath; } $query = self::mergeQuery($existingQuery, self::parameters($application, $channel)); return $basePath.'?'.$query; } /** * @return array{state: string, channel_code: string, application_id: string, status: string, submitted_at: string, hash: string} */ private static function parameters(Application $application, SignupChannel $channel): array { if ($application->submitted_at === null) { throw new InvalidArgumentException('Application submission timestamp is missing.'); } $parameters = [ 'state' => trim((string) $application->signup_channel_state), 'channel_code' => trim((string) $channel->channel_code), 'application_id' => (string) $application->getKey(), 'status' => 'submitted', 'submitted_at' => $application->submitted_at->toIso8601String(), ]; $parameters['hash'] = self::signature($parameters, $channel->shared_secret); return $parameters; } /** * @param array{state: string, channel_code: string, application_id: string, status: string, submitted_at: string} $parameters */ private static function signature(array $parameters, string $sharedSecret): string { return hash('sha256', implode('&', [ 'state='.trim($parameters['state']), 'channel_code='.trim($parameters['channel_code']), 'application_id='.trim($parameters['application_id']), 'status='.trim($parameters['status']), 'submitted_at='.trim($parameters['submitted_at']), 'secret='.trim($sharedSecret), ])); } /** * @param array $parameters */ private static function mergeQuery(string $existingQuery, array $parameters): string { $reservedKeys = array_fill_keys(array_keys($parameters), true); $keptParts = []; foreach (explode('&', $existingQuery) as $part) { if ($part === '') { continue; } $key = rawurldecode(explode('=', $part, 2)[0]); if (! isset($reservedKeys[$key])) { $keptParts[] = $part; } } $keptParts[] = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); return implode('&', $keptParts); } /** * @param array $parts */ private static function assembleUrl(array $parts): string { $authority = ''; if (isset($parts['user'])) { $authority .= $parts['user']; if (isset($parts['pass'])) { $authority .= ':'.$parts['pass']; } $authority .= '@'; } $authority .= $parts['host']; if (isset($parts['port'])) { $authority .= ':'.$parts['port']; } return $parts['scheme'].'://'.$authority.($parts['path'] ?? ''); } }