From a14f7077bbffec099a42d10fb1145e94f03732d7 Mon Sep 17 00:00:00 2001 From: Prabin Dahal Date: Fri, 13 Sep 2024 14:11:34 +0200 Subject: [PATCH] Initial commit --- .gitignore | 24 +++ Example.php | 84 ++++++++++ LICENSE | 21 +++ README.md | 277 +++++++++++++++++++++++++++++++ composer.json | 38 +++++ phpunit.xml | 12 ++ rector.php | 17 ++ src/AuthTypes/Authenticable.php | 15 ++ src/AuthTypes/Authentication.php | 24 +++ src/AuthTypes/DigestMD5.php | 16 ++ src/AuthTypes/Plain.php | 16 ++ src/Buffers/Buffer.php | 18 ++ src/Buffers/Response.php | 35 ++++ src/Exceptions/DeadSocket.php | 18 ++ src/Exceptions/StreamError.php | 15 ++ src/Loggers/Loggable.php | 33 ++++ src/Loggers/Logger.php | 69 ++++++++ src/Options.php | 259 +++++++++++++++++++++++++++++ src/Socket.php | 111 +++++++++++++ src/Xml/Stanzas/Auth.php | 51 ++++++ src/Xml/Stanzas/Iq.php | 98 +++++++++++ src/Xml/Stanzas/Message.php | 31 ++++ src/Xml/Stanzas/Presence.php | 67 ++++++++ src/Xml/Stanzas/Stanza.php | 31 ++++ src/Xml/Xml.php | 123 ++++++++++++++ src/XmppClient.php | 131 +++++++++++++++ tests/AuthPlainTest.php | 31 ++++ tests/DigestMD5Test.php | 26 +++ tests/OptionsTest.php | 124 ++++++++++++++ tests/ResponseBufferTest.php | 70 ++++++++ tests/XmlTest.php | 16 ++ 31 files changed, 1901 insertions(+) create mode 100644 .gitignore create mode 100644 Example.php create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 rector.php create mode 100644 src/AuthTypes/Authenticable.php create mode 100644 src/AuthTypes/Authentication.php create mode 100644 src/AuthTypes/DigestMD5.php create mode 100644 src/AuthTypes/Plain.php create mode 100644 src/Buffers/Buffer.php create mode 100644 src/Buffers/Response.php create mode 100644 src/Exceptions/DeadSocket.php create mode 100644 src/Exceptions/StreamError.php create mode 100644 src/Loggers/Loggable.php create mode 100644 src/Loggers/Logger.php create mode 100644 src/Options.php create mode 100644 src/Socket.php create mode 100644 src/Xml/Stanzas/Auth.php create mode 100644 src/Xml/Stanzas/Iq.php create mode 100644 src/Xml/Stanzas/Message.php create mode 100644 src/Xml/Stanzas/Presence.php create mode 100644 src/Xml/Stanzas/Stanza.php create mode 100644 src/Xml/Xml.php create mode 100644 src/XmppClient.php create mode 100644 tests/AuthPlainTest.php create mode 100644 tests/DigestMD5Test.php create mode 100644 tests/OptionsTest.php create mode 100644 tests/ResponseBufferTest.php create mode 100644 tests/XmlTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e3a521 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +vendor/ +composer.lock +.DS_Store +/node_modules +/vendor +.idea +/logs/ +.idea/ +/.idea +/.idea/* +/.vscode +/nbproject +/.vagrant +npm-debug.log +yarn-error.log +.phpunit.result.cache +Example2\.php +Example3\.php +Example4\.php +request\.xml +response\.xml +*.log +\.php_cs\.cache +.php-cs-fixer.cache diff --git a/Example.php b/Example.php new file mode 100644 index 0000000..1b99f5d --- /dev/null +++ b/Example.php @@ -0,0 +1,84 @@ +setHost(self::$host) + ->setPort(self::$port) + ->setUsername(self::$username) + ->setPassword(self::$password) + ; + + $client = new XmppClient($options); + $client->connect(); + + $client->iq->getRoster(); + + $client->message->send('Hello world', 'test@jabber.com'); + + // Uncomment if you want to manually enter raw XML (or call a function) and see a server response + // (new self)->sendRawXML($client); + + while (true) { + $response = $client->getResponse(); + $client->prettyPrint($response); + } + + $client->disconnect(); + } + + public function sendRawXML(XmppClient $client) + { + do { + $response = $client->getResponse(); + $client->prettyPrint($response); + + // if you provide a function name here, (i.e. getRoster ...arg) + // the function will be called instead of sending raw XML + $line = readline("\nEnter XML: "); + + if ($line == 'exit') { + break; + } + + $parsedLine = explode(' ', $line); + + if (method_exists($client, $parsedLine[0])) { + if (count($parsedLine) < 2) { + $client->{$parsedLine[0]}(); + + continue; + } + $client->{$parsedLine[0]}($parsedLine[1]); + + continue; + } + + if (@simplexml_load_string($line)) { + $client->send($line); + + continue; + } + + echo 'This is not a method nor a valid XML'; + } while ($line != 'exit'); + } +} + +ini_set('display_errors', 1); +ini_set('display_startup_errors', 1); +error_reporting(E_ALL); + +require __DIR__.'/vendor/autoload.php'; +Example::test(); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b955a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Dupor Marko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a23a532 --- /dev/null +++ b/README.md @@ -0,0 +1,277 @@ +# PHP client library for XMPP (Jabber) protocol + +> This library is based on [xmppo/xmpp-php](https://github.com/xmppo/xmpp-php) + +This is low level socket implementation for enabling PHP to +communicate with XMPP due to lack of such libraries online (at least ones I +could find that had decent documentation). + +XMPP core documentation can be found [here](https://xmpp.org/rfcs/rfc6120.html). + +# Usage + +You can see usage example in `Example.php` file by changing credentials to +point to your XMPP server and from project root run `php Example.php`. + +# Library usage + +## Initialization + +In order to start using the library you first need to instantiate a new `Options` +class. Host, username and password are mandatory fields, while port number, if omitted, +will default to `5222` which is XMPP default. + +Username can be either bare `JID` or in `JID/resource` form. If you are using a bare `JID` +the resource will be added automatically. You can override this by explicitly setting a +resource with `$client->iq->setResource()`. In the second case the username will be automatically +parsed to `username` and `resource` variables. In case of `JID/resource/xyz` format, everything +after second slash will be ignored. If both `JID/resource` is present as well as using the +`$client->iq->setResource()` method, which ever was defined last will take precedence. + +```php +$options = new Options(); + +$options + ->setHost($host) // required + ->setPort($port) // not required, defaults to 5222 + ->setUsername($username) // required + ->setPassword($password); // required +``` + +`Options` object is required for establishing the connection and every other subsequent +request, so once set it should not be changed. + +Once this is set you can instantiate a new `XmppClient` object and pass the `Options` object in. + +## XMPP client class explanation + +Since XMPP is all about 3 major stanzas, (**IQ, Message and Presence**), I've +created separate classes which are dependant on socket implementation so that you +can directly send XML by calling a stanza method. + +This means that 3 stanzas have been made available from `XmppClient` class constructor +to be used like a chained method on client's concrete class. + +Current logic thus is `$client->STANZA->METHOD()`. For example: + +```php +$client->iq->getRoster(); +$client->message->send(); +$client->presence->subscribe(); +``` + +## Connecting to the server +Beside being a stanza wrapper, `XmppClient` class offers a few public methods. + +`$client->connect()` method does a few things: +1. Connects to the socket which was initialized in `XmppClient` constructor +2. Opens an XML stream to exchange with the XMPP server +3. Tries to authenticate with the server based on provided credentials +4. Starts the initial communication with the server, bare minimum to get you started + +Current version supports `PLAIN` and `DIGEST-MD5` auth methods. + +TLS is supported by default. If server has support for TLS, library will +automatically try to connect with TLS and make the connection secure. + +If you'd like to explicitly disable this functionality, you can use `setUseTls(false)` +function on the `Options` instance so that TLS communication is disabled. Note +that this will function in environments where TLS is supported but not required. +If TLS is required, program will connect to it independently of the option you +set. + +## Sending raw data + +`send()` message is exposed as being public in `XmppClient` class, and its intention +is to send raw XML data to the server. For it to work correctly, XML which you send +has to be valid XML. + +## Getting raw response + +Server responses (or server side continuous XML session to be exact) can be retrieved with +`$client->getResponse()`. This should be used in an infinite loop or for more sustainable +solution in some WebSocket solution like [Ratchet](http://socketo.me/) if you'd like to +see continuous stream of everything coming from the server. + +If you would like to see the output of the received response in the console you can call the +`$client->prettyPrint($response)` method. + +## Receiving messages and other responses + +In case you are not interested in complete response which comes from server, you may also use +`$client->message->receive()` (`$client->getMessages()` was removed because it was just a shorthand +method for this one) which will match message tags with regex and return array of matched messages. +In case you'd like to see the response in the terminal, you can do something like this: + +```php +do { + $response = $client->message->receive(); + if($response) + echo print_r($response); +} while (true); +``` + +## Disconnect +Disconnect method sends closing XML to the server to end the currently open session and +closes the open socket. + +# Stanza method breakdown + +Remember from [here](#xmpp-client-class-explanation) -> `$client->STANZA->METHOD()` + +## Message + +`send()` - sending a message to someone. Takes 3 parameters of which the last one is +optional. First parameter is the actual message you'd like to send (body), second +one is recipient of the message and third one is type of message to be sent. This +defaults to `chat`. + +You can find possible types in [this RFC document](https://xmpp.org/rfcs/rfc3921.html#stanzas) + +`receive()` - covered in [this section](#receiving-messages-and-other-responses) + +## IQ + +`getRoster()` - takes no arguments and fetches current authenticated user roster. + +`setGroup()` - puts a given user in group you provide. Method takes two arguments: +first one being the group name which you will attach to given user, and other +being JID of that user. + +## Presence + +`setPriority()` - sets priority for given resource. First argument is an integer +`-128 <> 127`. If no second argument is given, priority will be set for currently used resource. +Other resource can be provided as a second argument whereas the priority will be set for that +specific resource. + +`subscribe()` - takes JID as an argument and asks that user for presence. + +`acceptSubscription()` - takes JID as an argument and accepts presence from that user. + +`declineSubscription()` - takes JID as an argument and declines presence from that user. + +## Sessions + +Sessions are currently being used only to differentiate logs if multiple connections +are being made. + +`XmppClient` class takes in second optional parameter `$sessionId` to which you can +forward session ID from your system, or it will be assigned automatically. + +You can disable sessions through `Options` object (`$options->setSessionManager(false)`), +as they can cause collision with already established sessions if being used inside +frameworks or similar. Needless to say if this is disabled, forwarding a second parameter +to `XmppClient` will not establish a new session. + +# More options (not required) + +`Options` object can take more options which may be chained but are not required. These are explained +and commented in the code directly in the `Options` class: + +```php +$options + ->setProtocol($protocol) // defaults to TCP + ->setResource($resource) // defaults to 'Pdahal_machine_' string + timestamp + ->setLogger($logger) // logger instance (logging explained below) + ->setAuthType($authType) // Takes on classes which implement Authenticable +``` + +## Socket options + +Most of the socket options are set by default so there is no need to tamper +with this class, however you can additionally change the timeout for the period +the socket will be alive when doing a `socket_read()`, and you can do that with +`$socket->setTimeout()`. + +## Logging + +Upon new established session the library is creating a `xmpp.log` log file in `logs/` folder: + +You can manually set logger when instantiating `Options` with `setLogger($logger)`. The method accepts +any object which implements `Loggable` interface so you can create your own implementation. + +Fun fact: this used to be a `PSR-3` logger interface, but I decided it was an overkill for this +stage of development. + +# Other + +`Example.php` has a `sendRawXML()` method which can be helpful with debugging. Method works in a way +that you can provide hand-written XML and send it to the server. On the other hand you can +also trigger a method by providing method name instead of XML. + +``` +Enter XML: foo <-- will send XML +Enter XML: getRoster <-- will run getRoster() method +Enter XML: requestPresence x@x.com <-- will run with argument requestPresence(x@x.com) +``` + +Some valid XMPP XML will be declined (like sending ``) because `simplexml_load_string()` +is not able to parse it as being a valid XML. In cases you need to do some custom stuff like +that and you are sure it is a XMPP valid XML, you can remove the parsing line and just let the +`send()` method do its magic. + + **Be aware! Be very aware!** sending an invalid XML to the server +will probably invalidate currently open XML session and you will probably need to restart the +script. This is highly experimental and not maintained really. It is a poor orphan method whose +parents have abandoned it and left in a hurry. It may prove to be Harry Potter one day, but hey... +we all clearly doubt it. You may be a special snowflake but no one likes you. Onward to +orphanagemobil! Begone method (just kidding, I won't delete it)! + +Who says readme's are boring. + +# Dev documentation + +For anyone willing to contribute, a quick breakdown of the structure: + +--- +- `Options.php` - everything that is variable about the library +- `Socket.php` - socket related implementation (connecting, reading, writing etc.) +- `XmppClient.php` - user friendly methods to interact with the library and stanza wrapper enabling users to call stanza methods through +instantiated class. This should contain as little logic as possible, turns out it's not so easy :) +--- + +- `AuthTypes` - contains methods to authenticate yourself to XMPP server. Besides concrete implementations there is also an +abstract class with minor logic to avoid duplication and interface having all the necessary methods should the need for new +auth type arise. + +- `Buffers` - implementation of buffer (or should I say a simple array) which gets filled when socket is calling the `receive()` +method, and it flushes on any read, which happens when calling `getResponse()` method for example. A brief history of why: I had +issues when a non-recoverable error would be thrown. In this situation I had to do 2 things: try to reconnect, show the error +to the user. The thing is that `getResponse()` returns string, and in case of reconnection the program execution would continue +returning either nothing or returning error string after the server already connected for the second time, thus misinforming the +user of the error which occurred before reconnection. Buffer was born. + +- `Exceptions` - this is more or less a standard. I am just overriding constructors so I can get my message in. + +- `Loggers` - containing logic to store logs to the `logs/xmpp.log` file. The idea was to keep several log types inside (full, simple, +no logger), but I found the one made to be sufficient. + +--- + +`Xml` + +- `Xml.php` - a trait consisting of way too many regex matching. This should be reformatted. +- `Stanzas` - main logic for all stanza communication with the server. This used to be just plain XML, but I have decided to +forward a socket dependency inside so that when you call the method, you actually also send it to the server. That used to be +something like `$this->socket->send($this->iq->getRoster())` which is correct from the programming perspective, but for the +simplicity sake, I like the `$client->iq->getRoster()` more. I'm open to other suggestions. + +--- + +## TODO's + +- **unit testing** - unfortunately I have been prolonging this for far too long, maybe there is a good soul out there who enjoys writing tests. +- **throttling** - when an unrecoverable error occurs (currently I am catching `` ones which break the stream) +reconnect is being made automatically. In case this is happening over and over again, program will try connecting indefinitely, +which is fine to any aspect except logs which will get clogged. I would like to throttle the connection so that it increases the +connection time each time it is unsuccessful. Problem here is that I can only catch the error when getting the response, and +response can be successful on the first XML exchange (for example when you send opening stream request), while breaking on the +second request. With this in mind my only idea was to implement throttling with timestamps or something. +- **sessions** - I presume this part is working correctly but should be tested from a framework +- **multiple connections** - I think this part works fine, but I am worried that triggering `getRoster()` while simultaneously +fetching a message may delete one server response. If you get in one batch both roster and message, it will be added to the buffer. +Calling back the response will get either roster or message, not both. And then buffer will be flushed. This is something that +needs thinking. +- **structure of XmppClient** - in order to enable the `$client->stanza->method` I need to instantiate all stanzas within the +class. I feel as this could be simplified. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a5e3657 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "pdahal/php-xmpp", + "description": "PHP library for XMPP based on `xmppo/xmpp-php`", + "version": "1.0.0", + "keywords": [ + "php", + "xmpp", + "jabber", + "library" + ], + "license": "MIT", + "authors": [ + { + "name": "Prabin Dahal", + "email": "mr.prabin@gmail.com" + }, + { + "name": "Marko Dupor", + "email": "marko.dupor@gmail.com" + } + ], + "type": "project", + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "~11.0", + "phpmd/phpmd": "~2.15", + "squizlabs/php_codesniffer": "~3.10", + "friendsofphp/php-cs-fixer": "~3.64", + "rector/rector": "~1.2" + }, + "autoload": { + "psr-4": { + "Pdahal\\Xmpp\\": "src/" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5e7ba30 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + tests + + + \ No newline at end of file diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..977e51d --- /dev/null +++ b/rector.php @@ -0,0 +1,17 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withPreparedSets(deadCode: true) + // uncomment to reach your current PHP version + ->withPhpSets(php82: true) + ->withTypeCoverageLevel(7) +; diff --git a/src/AuthTypes/Authenticable.php b/src/AuthTypes/Authenticable.php new file mode 100644 index 0000000..85992c2 --- /dev/null +++ b/src/AuthTypes/Authenticable.php @@ -0,0 +1,15 @@ +name; + } +} diff --git a/src/AuthTypes/DigestMD5.php b/src/AuthTypes/DigestMD5.php new file mode 100644 index 0000000..b8bb95a --- /dev/null +++ b/src/AuthTypes/DigestMD5.php @@ -0,0 +1,16 @@ +options->getUsername()}\x00{$this->options->getPassword()}"; + return self::quote(sha1($credentials)); + } +} diff --git a/src/AuthTypes/Plain.php b/src/AuthTypes/Plain.php new file mode 100644 index 0000000..ffbce1b --- /dev/null +++ b/src/AuthTypes/Plain.php @@ -0,0 +1,16 @@ +options->getUsername()}\x00{$this->options->getPassword()}"; + return self::quote(base64_encode($credentials)); + } +} diff --git a/src/Buffers/Buffer.php b/src/Buffers/Buffer.php new file mode 100644 index 0000000..7cf4aad --- /dev/null +++ b/src/Buffers/Buffer.php @@ -0,0 +1,18 @@ +response[] = $data; + } + } + + public function read(): string + { + $implodedResponse = $this->response != [] ? implode('', $this->response) : ''; + $this->flush(); + + return $implodedResponse; + } + + protected function flush(): void + { + $this->response = []; + } + + public function getCurrentBufferData(): array + { + return $this->response; + } +} diff --git a/src/Exceptions/DeadSocket.php b/src/Exceptions/DeadSocket.php new file mode 100644 index 0000000..10a29f8 --- /dev/null +++ b/src/Exceptions/DeadSocket.php @@ -0,0 +1,18 @@ +createLogFile(); + $this->log = fopen(self::LOG_FOLDER . '/' . self::LOG_FILE, 'a'); + } + + protected function createLogFile(): void + { + if (!file_exists(self::LOG_FOLDER)) { + mkdir(self::LOG_FOLDER, 0777, true); + } + } + + public function log(string $message): void + { + $this->writeToLog($message); + } + + public function logRequest(string $message): void + { + $this->writeToLog($message, "REQUEST"); + } + + public function logResponse(string $message): void + { + $this->writeToLog($message, "RESPONSE"); + } + + public function error(string $message): void + { + $this->writeToLog($message, "ERROR"); + } + + protected function writeToLog(string $message, string $type = ''): void + { + $prefix = date("Y.m.d H:i:s") . " " . session_id() . ($type ? " {$type}::" : " "); + $this->writeToFile($this->log, $prefix . "$message\n"); + } + + protected function writeToFile(mixed $file, string $message): void + { + try { + fwrite($file, $message); + } catch (Exception) { + // silent fail + } + } + + public function getFilePathFromResource(mixed $resource): string + { + $metaData = stream_get_meta_data($resource); + return $metaData["uri"]; + } +} diff --git a/src/Options.php b/src/Options.php new file mode 100644 index 0000000..011a467 --- /dev/null +++ b/src/Options.php @@ -0,0 +1,259 @@ +host) { + $this->getLogger()->error(__METHOD__.'::'.__LINE__. + ' No host found, please set the host variable'); + + throw new InvalidArgumentException(); + } + + return $this->host; + } + + public function setHost(string $host): Options + { + $this->host = trim($host); + + return $this; + } + + public function setDomain(string $domain): Options + { + $this->domain = $domain; + + return $this; + } + + /** + * @return mixed + */ + public function getDomain(): string + { + if ($this->domain == '') { + return $this->getHost(); + } + + return $this->domain; + } + + public function getPort(): int + { + return $this->port; + } + + public function setPort(int $port): Options + { + $this->port = $port; + + return $this; + } + + public function getUsername(): string + { + if ($this->username == '') { + $this->getLogger()->error(__METHOD__.'::'.__LINE__. + ' No username found, please set the username variable'); + + throw new InvalidArgumentException(); + } + + return $this->username; + } + + /** + * Try to assign a resource if it exists. If bare JID is forwarded, this will default to your username. + */ + public function setUsername(string $username): Options + { + $usernameResource = explode('/', $username); + + if (count($usernameResource) > 1) { + $this->setResource($usernameResource[1]); + $username = $usernameResource[0]; + } + + $this->username = trim($username); + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function getPassword(): string + { + if ($this->password == '') { + $this->getLogger()->error(__METHOD__.'::'.__LINE__. + ' No password found, please set the password variable'); + + throw new InvalidArgumentException(); + } + + return $this->password; + } + + public function setPassword(string $password): Options + { + $this->password = $password; + + return $this; + } + + public function getResource(): string + { + if ($this->resource == '') { + $this->resource = 'Pdahal_machine_'.time(); + } + + return $this->resource; + } + + public function setResource(string $resource): Options + { + $this->resource = trim($resource); + + return $this; + } + + public function getProtocol(): string + { + return $this->protocol; + } + + public function setProtocol(string $protocol): Options + { + $this->protocol = $protocol; + + return $this; + } + + public function fullSocketAddress(): string + { + $protocol = $this->getProtocol(); + $host = $this->getHost(); + $port = $this->getPort(); + + return "{$protocol}://{$host}:{$port}"; + } + + public function fullJid(): string + { + $username = $this->getUsername(); + $resource = $this->getResource(); + $host = $this->getHost(); + + return "{$username}@{$host}/{$resource}"; + } + + public function bareJid(): string + { + $username = $this->getUsername(); + $host = $this->getHost(); + + return "{$username}@{$host}"; + } + + public function setLogger(Loggable $logger): void + { + $this->logger = $logger; + } + + public function getLogger(): Loggable + { + if ($this->logger === null) { + $this->logger = new Logger(); + } + + return $this->logger; + } + + public function setUseTls(bool $enable): void + { + $this->useTls = $enable; + } + + public function usingTls(): bool + { + return $this->useTls; + } + + public function getAuthType(): Authenticable + { + if ($this->authType === null) { + $this->setAuthType(new Plain($this)); + } + + return $this->authType; + } + + public function setAuthType(Authenticable $authType): Options + { + $this->authType = $authType; + + return $this; + } +} diff --git a/src/Socket.php b/src/Socket.php new file mode 100644 index 0000000..2c4e94c --- /dev/null +++ b/src/Socket.php @@ -0,0 +1,111 @@ +responseBuffer = new Response(); + $this->connection = stream_socket_client($options->fullSocketAddress()); + + if (!$this->isAlive($this->connection)) { + throw new DeadSocket(); + } + + // stream_set_blocking($this->connection, true); + stream_set_timeout($this->connection, 0, $this->timeout); + $this->options = $options; + } + + public function disconnect(): void + { + fclose($this->connection); + } + + /** + * Sending XML stanzas to open socket. + */ + public function send(string $xml) + { + try { + fwrite($this->connection, $xml); + $this->options->getLogger()->logRequest(__METHOD__.'::'.__LINE__." {$xml}"); + // $this->checkSocketStatus(); + } catch (\Exception $e) { + $this->options->getLogger()->error(__METHOD__.'::'.__LINE__.' fwrite() failed '.$e->getMessage()); + + return; + } + + $this->receive(); + } + + public function receive(): void + { + $response = ''; + while ($out = fgets($this->connection)) { + $response .= $out; + } + + if (!$response) { + return; + } + + $this->responseBuffer->write($response); + $this->options->getLogger()->logResponse(__METHOD__.'::'.__LINE__." {$response}"); + } + + protected function isAlive($socket): bool + { + return $socket !== false; + } + + public function setTimeout($timeout): void + { + $this->timeout = $timeout; + } + + public function getResponseBuffer(): Response + { + return $this->responseBuffer; + } + + public function getOptions(): Options + { + return $this->options; + } + + protected function checkSocketStatus(): void + { + $status = socket_get_status($this->connection); + + // echo print_r($status); + + if ($status['eof']) { + $this->options->getLogger()->logResponse( + __METHOD__.'::'.__LINE__. + " ---Probably a broken pipe, restart connection\n" + ); + } + } +} diff --git a/src/Xml/Stanzas/Auth.php b/src/Xml/Stanzas/Auth.php new file mode 100644 index 0000000..ac26d37 --- /dev/null +++ b/src/Xml/Stanzas/Auth.php @@ -0,0 +1,51 @@ +socket->getResponseBuffer()->read(); + $options = $this->socket->getOptions(); + + $tlsSupported = self::isTlsSupported($response); + $tlsRequired = self::isTlsRequired($response); + + if ($tlsSupported && ($tlsRequired || (!$tlsRequired && $options->usingTls()))) { + $this->startTls(); + $this->socket->send(self::openXmlStream($options->getDomain())); + } + + $xml = $this->generateAuthXml($options->getAuthType()); + $this->socket->send($xml); + $this->socket->send(self::openXmlStream($options->getDomain())); + } + + protected function startTls(): void + { + $this->socket->send(""); + $response = $this->socket->getResponseBuffer()->read(); + + if (!self::canProceed($response)) { + $this->socket->getOptions()->getLogger()->error(__METHOD__ . '::' . __LINE__ . + " TLS authentication failed. Trying to continue but will most likely fail."); + return; + } + + stream_socket_enable_crypto($this->socket->connection, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT); + } + + protected function generateAuthXml(Authenticable $authType): string + { + $mechanism = $authType->getName(); + $encodedCredentials = $authType->encodedCredentials(); + $nameSpace = "urn:ietf:params:xml:ns:xmpp-sasl"; + + return "{$encodedCredentials}"; + } +} diff --git a/src/Xml/Stanzas/Iq.php b/src/Xml/Stanzas/Iq.php new file mode 100644 index 0000000..660b809 --- /dev/null +++ b/src/Xml/Stanzas/Iq.php @@ -0,0 +1,98 @@ +"; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function addToRoster(string $name, string $forJid, string $from, ?string $groupName = null): void + { + $group = $groupName ? "{$groupName}" : null; + $item = "{$group}"; + $query = "{$item}"; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function removeFromRoster(string $jid, string $myJid): void + { + $item = ""; + $query = "{$item}"; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function setResource(string $name): void + { + if (!trim($name)) { + return; + } + + $resource = "{$name}"; + $bind = "{$resource}"; + $xml = "{$bind}"; + + $this->socket->send($xml); + } + + public function setGroup(string $name, string $forJid): void + { + $group = "{$name}"; + $item = "{$group}"; + $query = "{$item}"; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function getServerVersion(): void + { + $query = ""; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function getServerFeatures(): void + { + $query = ""; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function getServerTime(): void + { + $query = ""; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function getFeatures(string $forJid): void + { + $query = ""; + $xml = "{$query}"; + + $this->socket->send($xml); + } + + public function ping(): void + { + $query = ""; + $xml = "{$query}"; + + $this->socket->send($xml); + } +} diff --git a/src/Xml/Stanzas/Message.php b/src/Xml/Stanzas/Message.php new file mode 100644 index 0000000..e9a1858 --- /dev/null +++ b/src/Xml/Stanzas/Message.php @@ -0,0 +1,31 @@ +generateMessageXml($body, $to, $type); + $this->socket->send($xml); + } + + public function receive(): array + { + $this->socket->receive(); + $rawResponse = $this->socket->getResponseBuffer()->read(); + return self::parseTag($rawResponse, "message"); + } + + protected function generateMessageXml(string $body, string $to, string $type): string + { + $to = self::quote($to); + $body = self::quote($body); + + $bodyXml = "{$body}"; + + return "{$bodyXml}"; + } +} diff --git a/src/Xml/Stanzas/Presence.php b/src/Xml/Stanzas/Presence.php new file mode 100644 index 0000000..d69f604 --- /dev/null +++ b/src/Xml/Stanzas/Presence.php @@ -0,0 +1,67 @@ +setPresence($to, 'subscribe'); + } + + public function unsubscribe(string $from): void + { + $this->setPresence($from, 'unsubscribe'); + } + + public function acceptSubscription(string $from): void + { + $this->setPresence($from, 'subscribed'); + } + + public function declineSubscription(string $from): void + { + $this->setPresence($from, 'unsubscribed'); + } + + protected function setPresence(string $to, string $type = "subscribe"): void + { + $xml = ""; + $this->socket->send($xml); + } + + /** + * Set priority to current resource by default, or optional other resource tied to the + * current username + * @param string|null $forResource + */ + public function setPriority(int $value, string $forResource = null): void + { + $from = self::quote($this->socket->getOptions()->fullJid()); + + if ($forResource) { + $from = $this->socket->getOptions()->getUsername() . "/$forResource"; + } + + $priority = "{$this->limitPriority($value)}"; + $xml = "{$priority}"; + + $this->socket->send($xml); + } + + protected function limitPriority(int $value): int + { + if ($value > self::PRIORITY_UPPER_BOUND) { + return self::PRIORITY_UPPER_BOUND; + } elseif ($value < self::PRIORITY_LOWER_BOUND) { + return self::PRIORITY_LOWER_BOUND; + } + + return $value; + } +} diff --git a/src/Xml/Stanzas/Stanza.php b/src/Xml/Stanzas/Stanza.php new file mode 100644 index 0000000..750f58e --- /dev/null +++ b/src/Xml/Stanzas/Stanza.php @@ -0,0 +1,31 @@ +socket->getOptions()->getLogger(); + $responseFilePath = $logger->getFilePathFromResource($logger->log); + $responseFile = fopen($responseFilePath, 'r'); + + return fread($responseFile, filesize($responseFilePath)); + } +} diff --git a/src/Xml/Xml.php b/src/Xml/Xml.php new file mode 100644 index 0000000..287317b --- /dev/null +++ b/src/Xml/Xml.php @@ -0,0 +1,123 @@ +"; + $to = "to='{$host}'"; + $stream = "xmlns:stream='http://etherx.jabber.org/streams'"; + $client = "xmlns='jabber:client'"; + $version = "version='1.0'"; + + return "{$xmlOpen}"; + } + + /** + * Closing tag for one XMPP stream session. + */ + public static function closeXmlStream(): string + { + return ''; + } + + public static function quote(string $input): string + { + return htmlspecialchars($input, ENT_XML1, 'utf-8'); + } + + public static function parseTag(string $rawResponse, string $tag): array + { + preg_match_all("#(<{$tag}.*?>.*?<\\/{$tag}>)#si", $rawResponse, $matched); + + return count($matched) <= 1 ? [] : array_map(fn ($match): false|\SimpleXMLElement => @simplexml_load_string($match), $matched[1]); + } + + public static function parseFeatures(string $xml): string + { + return self::matchInsideOfTag($xml, 'stream:features'); + } + + public static function isTlsSupported(string $xml): bool + { + $matchTag = self::matchCompleteTag($xml, 'starttls'); + + return !empty($matchTag); + } + + public static function isTlsRequired(string $xml): bool + { + if (!self::isTlsSupported($xml)) { + return false; + } + + $tls = self::matchCompleteTag($xml, 'starttls'); + preg_match('#required#', $tls, $match); + + return count($match) > 0; + } + + public static function matchCompleteTag(string $xml, string $tag): string + { + $matches = self::matchTag($xml, $tag); + + return is_array($matches) && count($matches) > 0 ? $matches[0] : ''; + } + + public static function matchInsideOfTag(string $xml, string $tag): string + { + $match = self::matchTag($xml, $tag); + + return is_array($match) && count($match) > 1 ? $match[1] : ''; + } + + private static function matchTag(string $xml, string $tag): array + { + $matches = null; + preg_match("#<{$tag}.*?>(.*)<\\/{$tag}>#", $xml, $matches); + + return count($matches) < 1 ? [] : $matches; + } + + public static function canProceed($xml): bool + { + preg_match("##", (string) $xml, $match); + + return count($match) > 0; + } + + public static function supportedAuthMethods($xml): array + { + preg_match_all('#(.*?)<\\/mechanism>#', (string) $xml, $match); + + return count($match) < 1 ? [] : $match[1]; + } + + public static function roster(string $xml): array + { + preg_match_all("#(.*?)<\\/iq>#", $xml, $match); + + return count($match) < 1 ? [] : $match[1]; + } + + /** + * @throws StreamError + */ + public static function checkForUnrecoverableErrors(string $response): void + { + preg_match_all('#(<(.*?) (.*?)\\/>)<\\/stream:error>#', $response, $streamErrors); + + if ((!empty($streamErrors[0])) && count($streamErrors[2]) > 0) { + throw new StreamError($streamErrors[2][0]); + } + } +} diff --git a/src/XmppClient.php b/src/XmppClient.php new file mode 100644 index 0000000..9a75de0 --- /dev/null +++ b/src/XmppClient.php @@ -0,0 +1,131 @@ +initDependencies(); + $this->initSession(); + } + + protected function initDependencies(): void + { + $this->socket = $this->initSocket(); + $this->initStanzas($this->socket); + } + + public function connect(): void + { + $this->openStream(); + $this->auth->authenticate(); + $this->iq->setResource($this->options->getResource()); + $this->sendInitialPresenceStanza(); + } + + public function send(string $xml): void + { + $this->socket->send($xml); + } + + public function getResponse(): string + { + $this->socket->receive(); + $response = $this->socket->getResponseBuffer()->read(); + + return $this->checkForErrors($response); + } + + public function prettyPrint(string $response): void + { + if ($response) { + $separator = "\n-------------\n"; + echo "{$separator} {$response} {$separator}"; + } + } + + public function disconnect(): void + { + $this->socket->send(self::closeXmlStream()); + $this->socket->disconnect(); + } + + protected function openStream(): void + { + $openStreamXml = self::openXmlStream($this->options->getDomain()); + $this->socket->send($openStreamXml); + } + + protected function sendInitialPresenceStanza(): void + { + $this->socket->send(''); + } + + protected function initStanzas(Socket $socket): void + { + $this->auth = new Auth($socket); + $this->iq = new Iq($socket); + $this->presence = new Presence($socket); + $this->message = new Message($socket); + } + + protected function initSession(): void + { + if (!headers_sent($filename, $linenum)) { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + } + } + + protected function initSocket(): Socket + { + return new Socket($this->options); + } + + /** + * @throws StreamError + */ + protected function checkForErrors(string $response): string + { + try { + self::checkForUnrecoverableErrors($response); + } catch (StreamError $e) { + $this->options->getLogger()->logResponse(__METHOD__.'::'.__LINE__." {$response}"); + $this->options->getLogger()->error(__METHOD__.'::'.__LINE__.' '.$e->getMessage()); + $this->reconnect(); + $response = ''; + } + + return $response; + } + + protected function reconnect(): void + { + $this->disconnect(); + $this->initDependencies(); + $this->connect(); + } +} diff --git a/tests/AuthPlainTest.php b/tests/AuthPlainTest.php new file mode 100644 index 0000000..39dbc49 --- /dev/null +++ b/tests/AuthPlainTest.php @@ -0,0 +1,31 @@ +optionsStub = $this->createMock(Options::class); + $this->optionsStub->method('getUsername')->willReturn('Foo'); + $this->optionsStub->method('getPassword')->willReturn('Bar'); + + $this->plainAuth = new Plain($this->optionsStub); + } + + public function testIfCredentialsAreEncodedRight(): void + { + $this->assertEquals('AEZvbwBCYXI=', $this->plainAuth->encodedCredentials()); + } +} diff --git a/tests/DigestMD5Test.php b/tests/DigestMD5Test.php new file mode 100644 index 0000000..895b8c6 --- /dev/null +++ b/tests/DigestMD5Test.php @@ -0,0 +1,26 @@ +optionsStub = $this->createMock(Options::class); + $this->optionsStub->method('getUsername')->willReturn('Foo'); + $this->optionsStub->method('getPassword')->willReturn('Bar'); + + $this->digestAuth = new DigestMD5($this->optionsStub); + } + + public function testIfCredentialsAreEncodedRight(): void + { + $this->assertEquals("80e25ae4140c338afbe621c1b3d7a9ec9480f731", $this->digestAuth->encodedCredentials()); + } +} diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php new file mode 100644 index 0000000..126b598 --- /dev/null +++ b/tests/OptionsTest.php @@ -0,0 +1,124 @@ +host = 'www.host.com'; + $this->username = 'foo'; + $this->password = 'bar'; + $this->port = 5222; + $this->options = new Options(); + + $this->options + ->setHost($this->host) + ->setPort($this->port) + ->setUsername($this->username) + ->setPassword($this->password) + ; + } + + public function testIfNoHostThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->options->setHost(''); + $this->options->getHost(); + } + + public function testIfNoUsernameThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->options->setUsername(''); + $this->options->getUsername(); + } + + public function testIfNoPasswordThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->options->setPassword(''); + $this->options->getPassword(); + } + + public function testIfHostGetsTrimmed(): void + { + $this->options->setHost(' host'); + $this->assertEquals('host', $this->options->getHost()); + + $this->options->setHost('host '); + $this->assertEquals('host', $this->options->getHost()); + + $this->options->setHost(' host '); + $this->assertEquals('host', $this->options->getHost()); + } + + public function testIfUsernameSplitResource(): void + { + $this->options->setUsername('user/resource'); + $this->assertEquals('user', $this->options->getUsername()); + $this->assertEquals('resource', $this->options->getResource()); + + $this->options->setUsername('user'); + $this->assertEquals('user', $this->options->getUsername()); + + $this->options->setUsername('user/resource/resource2/resource3'); + $this->assertEquals('user', $this->options->getUsername()); + $this->assertEquals('resource', $this->options->getResource()); + } + + public function testResourcePrecedence(): void + { + $this->options->setUsername('user/resource'); + $this->options->setResource('resource2'); + $this->assertEquals('user', $this->options->getUsername()); + $this->assertEquals('resource2', $this->options->getResource()); + + $this->options->setResource('resource2'); + $this->options->setUsername('user/resource'); + $this->assertEquals('user', $this->options->getUsername()); + $this->assertEquals('resource', $this->options->getResource()); + } + + public function testIfResourceGetsTrimmed(): void + { + $this->options->setResource(' resource'); + $this->assertEquals('resource', $this->options->getResource()); + + $this->options->setResource('resource '); + $this->assertEquals('resource', $this->options->getResource()); + + $this->options->setResource(' resource '); + $this->assertEquals('resource', $this->options->getResource()); + } + + public function testFullSocketAddress(): void + { + $this->assertEquals('tcp://www.host.com:5222', $this->options->fullSocketAddress()); + } + + public function testFullJid(): void + { + $this->options->setResource('resource'); + $this->assertEquals('foo@www.host.com/resource', $this->options->fullJid()); + } + + public function testBareJid(): void + { + $this->assertEquals('foo@www.host.com', $this->options->bareJid()); + } +} diff --git a/tests/ResponseBufferTest.php b/tests/ResponseBufferTest.php new file mode 100644 index 0000000..3eb2c0b --- /dev/null +++ b/tests/ResponseBufferTest.php @@ -0,0 +1,70 @@ +buffer = new Response(); + } + + public function testWriteString(): void + { + $this->buffer->write('test'); + $this->assertEquals(['test'], $this->buffer->getCurrentBufferData()); + } + + public function testWriteNumber(): void + { + $this->buffer->write(123); + $this->assertEquals(['123'], $this->buffer->getCurrentBufferData()); + } + + public function testWriteEmpty(): void + { + $this->buffer->write(''); + $this->assertEquals([], $this->buffer->getCurrentBufferData()); + } + + public function testWriteNull(): void + { + $this->buffer->write(null); + $this->assertEquals([], $this->buffer->getCurrentBufferData()); + } + + public function testRead(): void + { + $this->buffer->write('test'); + $response = $this->buffer->read(); + $this->assertEquals('test', $response); + } + + public function testReadNullInput(): void + { + $this->buffer->write(null); + $response = $this->buffer->read(); + $this->assertEquals('', $response); + } + + public function testFlushWithInput(): void + { + $this->buffer->write('test'); + $this->buffer->read(); + $this->assertEquals([], $this->buffer->getCurrentBufferData()); + } + + public function testFlushWithoutInput(): void + { + $this->buffer->read(); + $this->assertEquals([], $this->buffer->getCurrentBufferData()); + } +} diff --git a/tests/XmlTest.php b/tests/XmlTest.php new file mode 100644 index 0000000..0864d13 --- /dev/null +++ b/tests/XmlTest.php @@ -0,0 +1,16 @@ +"; + $this->assertEquals($expected, $this->openXmlStream($this->host)); + } +}