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
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'] ?? '');
|
|
}
|
|
}
|