diff --git a/README.md b/README.md index 9fea236..3dcd92e 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,52 @@ This is an implementation of the Mercure realtime protocol on steroids, built using ReactPHP. +**Mercureact is under development, and not ready for use in anything important.** + It is intended to be used standalone, but it may also be integrated into another PHP application. +## Installing + +As PHAR: + +* Download the latest [release](/noccy/mercureact/releases) from the forge. + +As Composer dependency: + +* `composer require noccylabs/mercureact` + +## Using as PHAR + +*TODO.* + +```shell +# Make a copy of the dist config and edit it +$ cp mercureact.conf.dist mercurect.conf +$ editor mercureact.conf +# Use the config file when launching +$ ./mercureact.phar -c mercureact.conf +``` + +## Using as dependency + +*TODO.* + ## ToDos -**Mercureact is under development, and not ready for use in anything important.** - -- [ ] Security Security Security - - [ ] Check JWTs on connect - - [ ] Check claims on subscribe and publish - - [ ] WebSocket authentication -- [ ] Subscription manager -- [ ] Publish events -- [ ] Server-Side Events distributor - - [x] Distribute events -- [ ] WebSocket distributor - - [ ] Setup subscriptions - - [ ] Dynamic subscriptions - - [x] Distribute events +* [ ] Read config from file +* [ ] Security Security Security + * [x] Check JWTs on connect + * [ ] Check claims on subscribe and publish + * [ ] WebSocket authentication +* [ ] Subscription/Topic manager + * [ ] Unify distribution +* [ ] Publish events +* [ ] Server-Side Events distributor + * [x] Distribute events over SSE +* [ ] WebSocket distributor + * [ ] Setup subscriptions + * [ ] Dynamic subscriptions + * [x] Distribute events over WS +* [ ] HTTP + * [ ] Break out HTTP middleware into classes + * [ ] HTTP middleware unittests \ No newline at end of file diff --git a/composer.lock b/composer.lock index 9e78880..e587b72 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "67776ecc95ce924ca24488a6af3615eb", + "content-hash": "794f155397a10dd2b61efedc40a661e8", "packages": [ { "name": "evenement/evenement", @@ -1676,16 +1676,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.59", + "version": "1.10.60", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e607609388d3a6d418a50a49f7940e8086798281" + "reference": "95dcea7d6c628a3f2f56d091d8a0219485a86bbe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e607609388d3a6d418a50a49f7940e8086798281", - "reference": "e607609388d3a6d418a50a49f7940e8086798281", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/95dcea7d6c628a3f2f56d091d8a0219485a86bbe", + "reference": "95dcea7d6c628a3f2f56d091d8a0219485a86bbe", "shasum": "" }, "require": { @@ -1734,20 +1734,20 @@ "type": "tidelift" } ], - "time": "2024-02-20T13:59:13+00:00" + "time": "2024-03-07T13:30:19+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.1", + "version": "11.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "89702be0ad026873ef3a1605fe8726254eef4e2c" + "reference": "9e0a298b4dc6438a1e70ac8e1b3ea4980ae5a09b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/89702be0ad026873ef3a1605fe8726254eef4e2c", - "reference": "89702be0ad026873ef3a1605fe8726254eef4e2c", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/9e0a298b4dc6438a1e70ac8e1b3ea4980ae5a09b", + "reference": "9e0a298b4dc6438a1e70ac8e1b3ea4980ae5a09b", "shasum": "" }, "require": { @@ -1804,7 +1804,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.1" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.2" }, "funding": [ { @@ -1812,7 +1812,7 @@ "type": "github" } ], - "time": "2024-03-02T07:34:25+00:00" + "time": "2024-03-09T16:56:49+00:00" }, { "name": "phpunit/php-file-iterator", @@ -2061,16 +2061,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.0.4", + "version": "11.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3f4261269c91370e9b2b3f64cc76c617c442c35a" + "reference": "da2de3900beab025398ba37705b0f5ecafb3e1ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3f4261269c91370e9b2b3f64cc76c617c442c35a", - "reference": "3f4261269c91370e9b2b3f64cc76c617c442c35a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/da2de3900beab025398ba37705b0f5ecafb3e1ab", + "reference": "da2de3900beab025398ba37705b0f5ecafb3e1ab", "shasum": "" }, "require": { @@ -2141,7 +2141,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.4" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.5" }, "funding": [ { @@ -2157,7 +2157,7 @@ "type": "tidelift" } ], - "time": "2024-02-29T16:21:10+00:00" + "time": "2024-03-09T12:12:14+00:00" }, { "name": "sebastian/cli-parser", diff --git a/make-phar.sh b/make-phar.sh new file mode 100755 index 0000000..916d350 --- /dev/null +++ b/make-phar.sh @@ -0,0 +1,12 @@ +#!/bin/bash +REPO=$PWD +DIR=mercureact +pushd /tmp +rm -rf /tmp/$DIR +git clone "$REPO" +cd $DIR +composer install --no-dev +pharlite +popd +mv /tmp/$DIR/*.phar . +rm -rf /tmp/$DIR diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..e68bade --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + level: 5 + + excludePaths: + - doc + - vendor + - tests + + # Paths to include in the analysis + paths: + - src + diff --git a/src/Http/Exception/SecurityException.php b/src/Http/Exception/SecurityException.php index 16ff6c7..2112d0d 100644 --- a/src/Http/Exception/SecurityException.php +++ b/src/Http/Exception/SecurityException.php @@ -7,4 +7,5 @@ use Exception; class SecurityException extends Exception { const ERR_ACCESS_DENIED = 50001; + const ERR_NO_PERMISSION = 50002; } \ No newline at end of file diff --git a/src/Http/Server.php b/src/Http/Server.php index f5e370e..b598a00 100644 --- a/src/Http/Server.php +++ b/src/Http/Server.php @@ -80,6 +80,7 @@ class Server */ private function createHttpServer(array $options): HttpServer { + // TODO break out the middleware to facilitate testing return new HttpServer( $this->rejectionWrappingMiddleware(...), $this->checkRequestSecurityMiddleware(...), @@ -91,7 +92,7 @@ class Server } /** - * + * Resolves unhandled requests with a 404 error * * @param ServerRequestInterface $request * @return PromiseInterface @@ -106,7 +107,8 @@ class Server } /** - * + * Wraps rejections into error messages, and also does some sanity checks on the returned + * data, making sure it is a response. * * @param ServerRequestInterface $request * @param callable $next @@ -275,6 +277,22 @@ class Server 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()); + $data = []; + foreach ($body as $param) { + if (!str_contains($param, "=")) + throw new RequestException("Invalid request data", RequestException::ERR_INVALID_REQUEST_DATA); + [ $name, $value ] = array_map('urldecode', explode("=", $param, 2)); + if (in_array($name, [ 'topic' ])) { + if (!isset($data[$name])) + $data[$name] = []; + $data[$name][] = $value; + } else { + $data[$name] = $value; + } + } + // Grab the JWT token from the requests authorization attribute $tok = $request->getAttribute('authorization'); if ($tok instanceof JWTToken) { @@ -282,22 +300,9 @@ class Server if (isset($claims['mercure']['publish'])) { $publishClaims = $claims['mercure']['publish']; // TODO check topic against publishClaims - } - } - - // Parse out the urlencoded body. Pretty sure there is a better way to do this? - $body = explode("&", (string)$request->getBody()); - $data = []; - foreach ($body as $param) { - if (!str_contains($param, "=")) throw new RequestException("Invalid request data", RequestException::ERR_INVALID_REQUEST_DATA); - [ $name, $value ] = array_map('urldecode', explode("=", $param, 2)); - // FIXME support multiple topics? - if (in_array($name, [ 'topic' ])) { - if (!isset($data[$name])) - $data[$name] = []; - $data[$name][] = $value; - } else { - $data[$name] = $value; + if (!$this->checkTopicClaims($data['topic']??[], $publishClaims)) { + throw new SecurityException("Insufficient permissions for publish", SecurityException::ERR_NO_PERMISSION); + } } } @@ -316,6 +321,18 @@ class Server return Response::plaintext("urn:uuid:".$message->id."\n"); } + private function checkTopicClaims(string|array $topic, array $claims): bool + { + foreach ((array)$topic as $match) { + foreach ($claims as $claim) { + if ($claim === "*") return true; + if ($claim === $match) return true; + // TODO implement full matching + } + } + return false; + } + /** * * @@ -327,7 +344,7 @@ class Server foreach ($this->webSocketClients as $webSocket) { $webSocket->write(json_encode([ 'type' => $message->type, - //'topic' => $data['topic'], + 'topic' => $message->topic, 'data' => (@json_decode($message->data))??$message->data ])); }