commit a14f7077bbffec099a42d10fb1145e94f03732d7 Author: Prabin Dahal Date: Fri Sep 13 14:11:34 2024 +0200 Initial commit 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)); + } +}