Skip to content

Instantly share code, notes, and snippets.

@lostfocus
Created February 23, 2024 13:18
Show Gist options
  • Save lostfocus/a6202e2aa8cd8073e686b2c97b955595 to your computer and use it in GitHub Desktop.
Save lostfocus/a6202e2aa8cd8073e686b2c97b955595 to your computer and use it in GitHub Desktop.
<?php
declare(strict_types=1);
namespace App\Service\ActivityPub;
use App\Entity\User;
use JsonException;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use function base64_encode;
use function gmdate;
use function hash;
use function openssl_sign;
use function sprintf;
use const OPENSSL_ALGO_SHA256;
readonly class Client
{
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $activitypubLogger
) {
}
/**
* @param string $inboxUrl
* @param array<string, mixed> $activity
* @param User|null $user
* @return void
* @throws ClientExceptionInterface
* @throws JsonException
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public function sendToInbox(string $inboxUrl, array $activity, ?User $user = null): void
{
if ($user === null) {
$this->activitypubLogger->debug('No user given', [
'inboxUrl' => $inboxUrl,
'message' => $activity,
]);
return;
}
$body = json_encode($activity, JSON_THROW_ON_ERROR);
$date = gmdate('D, d M Y H:i:s T');
$digest = $this->generateDigest($body);
$signature = $this->generateSignature($user, $inboxUrl, $date, $digest);
$this->activitypubLogger->debug(__FUNCTION__, [
'inboxUrl' => $inboxUrl,
'message' => $activity,
'Accept' => 'application/activity+json,application/ld+json,application/json',
'Content-Type' => 'application/activity+json',
'Digest' => "SHA-256=$digest",
'Signature' => $signature,
'Date' => $date,
]);
try {
$response = $this->httpClient->request('POST', $inboxUrl, [
'body' => $body,
'headers' => [
'Accept' => 'application/activity+json,application/ld+json,application/json',
'Content-Type' => 'application/activity+json',
'Digest' => "SHA-256=$digest",
'Signature' => $signature,
'Date' => $date,
],
]);
} catch (TransportExceptionInterface $e) {
$this->activitypubLogger->error($e->getMessage(), [
'type' => $e::class,
'message' => $e->getMessage(),
'code' => $e->getCode(),
]);
throw $e;
}
try {
$content = $response->getContent();
} catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {
/** @noinspection PhpUnhandledExceptionInspection */
$content = $response->getContent(false);
$this->activitypubLogger->error($e->getMessage(), [
'type' => $e::class,
'message' => $e->getMessage(),
'code' => $e->getCode(),
'content' => $content,
]);
throw $e;
}
$this->activitypubLogger->debug('response', [
'content' => $content,
'code' => $response->getStatusCode(),
]);
}
private function generateDigest(string $body): string
{
return base64_encode(hash('sha256', $body, true));
}
private function generateSignature(User $user, string $inboxUrl, string $date, string $digest, string $method = 'post'): string
{
if ($user->getPrivateKey() === null) {
throw new RuntimeException();
}
$urlParts = parse_url($inboxUrl);
if (!is_array($urlParts)) {
throw new RuntimeException();
}
if (!array_key_exists('host', $urlParts) || !array_key_exists('path', $urlParts)) {
throw new RuntimeException();
}
$host = $urlParts['host'];
$path = $urlParts['path'];
if (array_key_exists('query', $urlParts)) {
$query = $urlParts['query'];
if (trim($query) !== '') {
$path .= '?'.$query;
}
}
$signed_string = sprintf("(request-target): %s %s\nhost: %s\ndate: %s\ndigest: SHA-256=%s", $method, $path, $host, $date, $digest);
openssl_sign($signed_string, $signature, $user->getPrivateKey(), OPENSSL_ALGO_SHA256);
$signature = base64_encode($signature); // phpcs:ignore
$key_id = $user->getProfileUrl().'#main-key';
return sprintf('keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature);
}
/**
* @param string $webfingerUrl
* @return array<string, mixed>
* @throws ClientExceptionInterface
* @throws JsonException
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public function webfinger(string $webfingerUrl): array
{
$webfingerResult = $this->httpClient->request('GET', $webfingerUrl, [
'headers' => [
'Accept' => 'application/json',
],
]);
$content = json_decode($webfingerResult->getContent(), true, 512, JSON_THROW_ON_ERROR);
if (is_array($content)) {
return $content;
}
return [];
}
/**
* @return array<string, mixed>
* @throws ClientExceptionInterface
* @throws JsonException
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public function load(string $url, ?User $user = null): array
{
$headers = [
'Accept' => 'application/activity+json,application/ld+json,application/json',
];
if ($user !== null) {
$date = gmdate('D, d M Y H:i:s T');
$digest = $this->generateDigest('');
$signature = $this->generateSignature($user, $url, $date, $digest, 'get');
$headers['Digest'] = 'SHA-256='.$digest;
$headers['Signature'] = $signature;
$headers['Date'] = $date;
}
$result = $this->httpClient->request('GET', $url, [
'headers' => $headers,
]);
$content = json_decode($result->getContent(), true, 512, JSON_THROW_ON_ERROR);
if (is_array($content)) {
return $content;
}
return [];
}
}
@lostfocus
Copy link
Author

https://gist.github.com/lostfocus/a6202e2aa8cd8073e686b2c97b955595#file-client-php-L151 should be:

'Accept' => 'application/jrd+json,application/json',

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment