You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

149 lines
5.1 KiB

<?php
namespace App\Support;
use App\Models\Application;
use App\Models\SignupChannel;
use InvalidArgumentException;
class ChannelCallbackUrlBuilder
{
/**
* 回跳签名原文固定为以下字段顺序,使用 trim 后的原始值,不做 URL 编码:
* state={state}&channel_code={channel_code}&application_id={application_id}&status={status}&submitted_at={submitted_at}&secret={shared_secret}
*/
public static function build(Application $application, SignupChannel $channel): string
{
$callbackUrl = trim((string) $channel->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<string, string> $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<string, mixed> $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'] ?? '');
}
}