2024-03-10 20:22:28 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
namespace NoccyLabs\Mercureact\Http\Middleware;
|
|
|
|
|
2024-03-12 01:45:21 +01:00
|
|
|
use LDAP\Result;
|
2024-03-10 20:22:28 +01:00
|
|
|
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;
|
2024-03-11 22:12:01 +01:00
|
|
|
use Rize\UriTemplate\UriTemplate;
|
2024-03-10 20:22:28 +01:00
|
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
|
|
|
|
class MercureHandler
|
|
|
|
{
|
|
|
|
private LoopInterface $loop;
|
|
|
|
|
2024-03-12 01:45:21 +01:00
|
|
|
private array $seenMessageIds = [];
|
|
|
|
|
|
|
|
private int $seenIdHistorySize = 100;
|
|
|
|
|
2024-03-10 20:22:28 +01:00
|
|
|
public function __construct(
|
|
|
|
private Configuration $config,
|
|
|
|
private TopicManager $topicManager,
|
|
|
|
?LoopInterface $loop=null
|
|
|
|
)
|
|
|
|
{
|
|
|
|
$this->loop = $loop ?? Loop::get();
|
2024-03-12 01:45:21 +01:00
|
|
|
$this->seenIdHistorySize = $this->config->getDuplicateIdHistorySize();
|
2024-03-10 20:22:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
2024-03-11 22:29:17 +01:00
|
|
|
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
|
2024-03-11 22:29:17 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2024-03-12 01:45:21 +01:00
|
|
|
if ($this->config->getRejectDuplicateMessages() && !empty($data['id'])) {
|
|
|
|
if (in_array($data['id'], $this->seenMessageIds)) {
|
|
|
|
return Response::plaintext("Duplicate message id")->withStatus(Response::STATUS_BAD_REQUEST);
|
|
|
|
}
|
|
|
|
array_push($this->seenMessageIds, $data['id']);
|
|
|
|
$this->seenMessageIds = array_slice($this->seenMessageIds, -100, 100);
|
|
|
|
}
|
|
|
|
|
2024-03-10 20:22:28 +01:00
|
|
|
// Put an id in there if none already
|
2024-03-12 01:45:21 +01:00
|
|
|
if (empty($data['id']) || $this->config->getOverwriteMessageIds()) {
|
2024-03-10 20:22:28 +01:00
|
|
|
$data['id'] = (string)Uuid::v7();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Attempt to create the message
|
|
|
|
$message = Message::fromData($data);
|
|
|
|
|
|
|
|
$this->loop->futureTick(function () use ($message) {
|
2024-03-12 01:45:21 +01:00
|
|
|
$this->topicManager->publish($message);
|
2024-03-10 20:22:28 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
2024-03-11 22:12:01 +01:00
|
|
|
// 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
|
|
|
}
|
|
|
|
|
|
|
|
}
|