mercureact/src/Http/Middleware/MercureHandler.php

215 lines
7.1 KiB
PHP
Raw Normal View History

2024-03-10 20:22:28 +01:00
<?php
namespace NoccyLabs\Mercureact\Http\Middleware;
use NoccyLabs\Mercureact\Broker\Message;
2024-03-10 23:06:00 +01:00
use NoccyLabs\Mercureact\Broker\SseSubscriber;
2024-03-10 20:22:28 +01:00
use NoccyLabs\Mercureact\Broker\TopicManager;
use NoccyLabs\Mercureact\Configuration;
2024-03-10 23:06:00 +01:00
use NoccyLabs\Mercureact\Http\Exception\RequestException;
use NoccyLabs\Mercureact\Http\Exception\SecurityException;
2024-03-10 20:22:28 +01:00
use NoccyLabs\SimpleJWT\JWTToken;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Http\Message\Response;
use React\Promise\Promise;
use React\Promise\PromiseInterface;
use React\Stream\ThroughStream;
use Rize\UriTemplate\UriTemplate;
2024-03-10 20:22:28 +01:00
use Symfony\Component\Uid\Uuid;
class MercureHandler
{
private LoopInterface $loop;
public function __construct(
private Configuration $config,
private TopicManager $topicManager,
?LoopInterface $loop=null
)
{
$this->loop = $loop ?? Loop::get();
}
/**
* Mecure handler middleware
*
* @param ServerRequestInterface $request
* @param callable $next
* @return PromiseInterface
*/
public function __invoke(ServerRequestInterface $request, callable $next): PromiseInterface
{
return new Promise(
function (callable $resolve, callable $reject) use ($next, $request) {
if ($request->getUri()->getPath() == "/.well-known/mercure") {
if ($request->getMethod() == 'POST') {
$resolve($this->handleMercurePublish($request));
return;
}
$resolve($this->handleMercureClient($request));
} else {
$resolve($next($request));
}
}
);
}
/**
*
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
private function handleMercureClient(ServerRequestInterface $request): ResponseInterface
{
$responseStream = new ThroughStream();
$response = new Response(
body: $responseStream
);
2024-03-10 23:06:00 +01:00
$subscriber = new SseSubscriber($responseStream, $request);
$query = $request->getUri()->getQuery();
$query = explode("&", $query);
$topics = [];
foreach ($query as $param) {
if (!str_contains($param, "="))
throw new RequestException(
message: "Invalid request data",
code: RequestException::ERR_INVALID_REQUEST_DATA
);
[ $name, $value ] = array_map('urldecode', explode("=", $param, 2));
2024-03-11 01:20:45 +01:00
if ($name === 'topic' || $name === 'topic[]') $topics[] = $value;
2024-03-10 23:06:00 +01:00
}
2024-03-11 00:36:34 +01:00
// Grab the JWT token from the requests authorization attribute
if ($request->getAttribute('authorized')) {
$claims = $request->getAttribute('mercure.subscribe');
if (!$this->checkTopicClaims($topics, $claims)) {
throw new SecurityException(
message: "Insufficient permissions for subscribe",
code: SecurityException::ERR_NO_PERMISSION
);
2024-03-11 00:36:34 +01:00
}
} else {
2024-03-11 00:50:15 +01:00
// Disallow if we don't allow anonymous subscribers. Note that anonymous
// subscribers will not receive updates marked as private.
if (!$this->config->getAllowAnonymousSubscribe()) {
throw new SecurityException(
message: "Anonymous access disallowed",
code: SecurityException::ERR_ACCESS_DENIED
);
}
2024-03-11 00:36:34 +01:00
}
2024-03-10 23:06:00 +01:00
$this->topicManager->subscribe($subscriber, $topics);
$responseStream->on('close', function () use ($subscriber, $topics) {
$this->topicManager->unsubscribe($subscriber, $topics);
2024-03-10 20:22:28 +01:00
});
return $response
->withHeader("Cache-Control", "no-store")
->withHeader("Content-Type", "text/event-stream");
}
/**
*
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
private function handleMercurePublish(ServerRequestInterface $request): ResponseInterface
{
if ($request->getHeaderLine('content-type') !== 'application/x-www-form-urlencoded') {
throw new \Exception("Invalid request");
}
// Parse out the urlencoded body. Pretty sure there is a better way to do this?
$body = explode("&", (string)$request->getBody());
2024-03-11 01:20:45 +01:00
$data = [
'topic' => []
];
2024-03-10 20:22:28 +01:00
foreach ($body as $param) {
if (!str_contains($param, "="))
2024-03-11 00:50:15 +01:00
throw new RequestException(
message: "Invalid request data",
code: RequestException::ERR_INVALID_REQUEST_DATA
);
2024-03-10 20:22:28 +01:00
[ $name, $value ] = array_map('urldecode', explode("=", $param, 2));
2024-03-11 01:20:45 +01:00
if ($name === 'topic' || $name === 'topic[]') {
$data['topic'][] = $value;
2024-03-10 20:22:28 +01:00
} else {
$data[$name] = $value;
}
}
// Grab the JWT token from the requests authorization attribute
if ($request->getAttribute('authorized')) {
$claims = $request->getAttribute('mercure.publish');
// check topic against publishClaims
if (!$this->checkTopicClaims($data['topic']??[], $claims)) {
throw new SecurityException(
message: "Insufficient permissions for publish",
code: SecurityException::ERR_NO_PERMISSION
);
2024-03-10 20:22:28 +01:00
}
} else {
2024-03-11 00:36:34 +01:00
// reject if access denied
throw new SecurityException(
message: "Access denied",
code: SecurityException::ERR_ACCESS_DENIED
);
2024-03-10 20:22:28 +01:00
}
// Put an id in there if none already
// TODO add a configurable for this
if (!isset($data['id'])) {
$data['id'] = (string)Uuid::v7();
}
// Attempt to create the message
$message = Message::fromData($data);
$this->loop->futureTick(function () use ($message) {
$this->publishMercureMessage($message);
});
return Response::plaintext("urn:uuid:".$message->id."\n");
}
2024-03-11 00:36:34 +01:00
private function checkTopicClaims(string|array $topic, array $claims): bool
2024-03-10 20:22:28 +01:00
{
2024-03-11 00:36:34 +01:00
$matched = 0;
2024-03-10 20:22:28 +01:00
foreach ((array)$topic as $match) {
foreach ($claims as $claim) {
2024-03-11 00:36:34 +01:00
if (($claim === "*") || ($claim === $match)) {
$matched++;
break;
}
// TODO make sure that UriTemplate parsing works
if ((new UriTemplate())->extract($claim, $match, true)) {
$matched++;
break;
}
2024-03-10 20:22:28 +01:00
}
}
2024-03-11 00:36:34 +01:00
return ($matched == count($topic));
2024-03-10 20:22:28 +01:00
}
/**
*
*
* @param Message $message
* @return void
*/
private function publishMercureMessage(Message $message): void
{
2024-03-10 23:06:00 +01:00
$this->topicManager->publish($message);
2024-03-10 20:22:28 +01:00
}
}