Initial commit

This commit is contained in:
2024-09-13 14:11:34 +02:00
commit a14f7077bb
31 changed files with 1901 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -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

84
Example.php Normal file
View File

@ -0,0 +1,84 @@
<?php
namespace Pdahal\Xmpp;
class Example
{
protected static string $host = 'host.example.com';
protected static int $port = 5222;
protected static string $username = 'foo';
protected static string $password = 'bar';
public static function test()
{
$options = new Options();
$options
->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();

21
LICENSE Normal file
View File

@ -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.

277
README.md Normal file
View File

@ -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: <xml>foo</xml> <-- 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 `<presence/>`) 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 `<stream:error>` 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.

38
composer.json Normal file
View File

@ -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/"
}
}
}

12
phpunit.xml Normal file
View File

@ -0,0 +1,12 @@
<phpunit bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
stopOnFailure="true">
<testsuites>
<testsuite name="Unit Tests">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

17
rector.php Normal file
View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Renaming\Rector\Name\RenameClassRector;
return RectorConfig::configure()
->withPaths([
__DIR__.'/src',
__DIR__.'/tests',
])
->withPreparedSets(deadCode: true)
// uncomment to reach your current PHP version
->withPhpSets(php82: true)
->withTypeCoverageLevel(7)
;

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\AuthTypes;
interface Authenticable
{
/**
* Based on auth type, return the right format of credentials to be sent to the server.
*/
public function encodedCredentials(): string;
public function getName(): string;
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\AuthTypes;
use Pdahal\Xmpp\Options;
use Pdahal\Xmpp\Xml\Xml;
abstract class Authentication implements Authenticable
{
use Xml;
protected string $name;
public function __construct(protected Options $options)
{
}
public function getName(): string
{
return $this->name;
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\AuthTypes;
class DigestMD5 extends Authentication
{
protected string $name = 'DIGEST-MD5';
public function encodedCredentials(): string
{
$credentials = "\x00{$this->options->getUsername()}\x00{$this->options->getPassword()}";
return self::quote(sha1($credentials));
}
}

16
src/AuthTypes/Plain.php Normal file
View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\AuthTypes;
class Plain extends Authentication
{
protected string $name = 'PLAIN';
public function encodedCredentials(): string
{
$credentials = "\x00{$this->options->getUsername()}\x00{$this->options->getPassword()}";
return self::quote(base64_encode($credentials));
}
}

18
src/Buffers/Buffer.php Normal file
View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Buffers;
interface Buffer
{
/**
* Write to buffer (add to array of values)
*/
public function write(string $data): void;
/**
* Read from buffer and delete the data
*/
public function read(): string;
}

35
src/Buffers/Response.php Normal file
View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Buffers;
class Response implements Buffer
{
protected array $response = [];
public function write(?string $data = null): void
{
if ($data) {
$this->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;
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Exceptions;
use Exception;
class DeadSocket extends Exception
{
public function __construct()
{
$errorCode = socket_last_error();
$errorMsg = socket_strerror($errorCode);
parent::__construct("Couldn't create socket: [$errorCode] $errorMsg");
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Exceptions;
use Exception;
class StreamError extends Exception
{
public function __construct(string $streamErrorType)
{
parent::__construct("Unrecoverable stream error ({$streamErrorType}), trying to reconnect...");
}
}

33
src/Loggers/Loggable.php Normal file
View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Loggers;
interface Loggable
{
/**
* Standard log message
*/
public function log(string $message): void;
/**
* Shorthand method for logging with prepended "REQUEST" string
*/
public function logRequest(string $message): void;
/**
* Shorthand method for logging with prepended "RESPONSE" string
*/
public function logResponse(string $message): void;
/**
* Shorthand method for logging with prepended "ERROR" string
*/
public function error(string $message): void;
/**
* Returns relative path from given resource
*/
public function getFilePathFromResource(mixed $resource): string;
}

69
src/Loggers/Logger.php Normal file
View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Loggers;
use Exception;
class Logger implements Loggable
{
public mixed $log;
public const LOG_FOLDER = "logs";
public const LOG_FILE = "xmpp.log";
public function __construct()
{
$this->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"];
}
}

259
src/Options.php Normal file
View File

@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp;
use Pdahal\Xmpp\AuthTypes\Authenticable;
use Pdahal\Xmpp\AuthTypes\Plain;
use Pdahal\Xmpp\Loggers\Loggable;
use Pdahal\Xmpp\Loggers\Logger;
use Psr\Log\InvalidArgumentException;
class Options
{
/**
* Hostname of XMPP server.
*/
protected string $host = '';
/**
* Domain Name of XMPP server.
*/
protected string $domain = '';
/**
* XMPP server port. Usually 5222.
*/
protected int $port = 5222;
/**
* Protocol used for socket connection, defaults to TCP.
*/
protected string $protocol = 'tcp';
/**
* Username to authenticate on XMPP server.
*/
protected string $username = '';
/**
* Password to authenticate on XMPP server.
*/
protected string $password = '';
/**
* XMPP resource.
*/
protected string $resource = '';
/**
* Custom logger interface.
*/
protected ?Loggable $logger = null;
/**
* Use TLS if available.
*/
protected bool $useTls = true;
/**
* Auth type (Authentication/AuthTypes/).
*/
protected ?Authenticable $authType = null;
public function getHost(): string
{
if (!$this->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;
}
}

111
src/Socket.php Normal file
View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp;
use Pdahal\Xmpp\Buffers\Response;
use Pdahal\Xmpp\Exceptions\DeadSocket;
class Socket
{
public mixed $connection;
protected Response $responseBuffer;
protected Options $options;
/**
* Period in microseconds for imposed timeout while doing socket_read().
*/
protected int $timeout = 150000;
/**
* Socket constructor.
*
* @throws DeadSocket
*/
public function __construct(Options $options)
{
$this->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"
);
}
}
}

51
src/Xml/Stanzas/Auth.php Normal file
View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Xml\Stanzas;
use Pdahal\Xmpp\AuthTypes\Authenticable;
class Auth extends Stanza
{
public function authenticate(): void
{
$response = $this->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("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");
$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 "<auth xmlns='{$nameSpace}' mechanism='{$mechanism}'>{$encodedCredentials}</auth>";
}
}

98
src/Xml/Stanzas/Iq.php Normal file
View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Xml\Stanzas;
class Iq extends Stanza
{
public function getRoster(): void
{
$query = "<query xmlns='jabber:iq:roster'/>";
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
$this->socket->send($xml);
}
public function addToRoster(string $name, string $forJid, string $from, ?string $groupName = null): void
{
$group = $groupName ? "<group>{$groupName}</group>" : null;
$item = "<item jid='{$forJid}' name='{$name}'>{$group}</item>";
$query = "<query xmlns='jabber:iq:roster'>{$item}</query>";
$xml = "<iq type='set' id='{$this->uniqueId()}' from='{$from}'>{$query}</iq>";
$this->socket->send($xml);
}
public function removeFromRoster(string $jid, string $myJid): void
{
$item = "<item jid='{$jid}' subscription='remove'/>";
$query = "<query xmlns='jabber:iq:roster'>{$item}</query>";
$xml = "<iq type='set' id='{$this->uniqueId()}' from='{$myJid}'>{$query}</iq>";
$this->socket->send($xml);
}
public function setResource(string $name): void
{
if (!trim($name)) {
return;
}
$resource = "<resource>{$name}</resource>";
$bind = "<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'>{$resource}</bind>";
$xml = "<iq type='set' id='{$this->uniqueId()}'>{$bind}</iq>";
$this->socket->send($xml);
}
public function setGroup(string $name, string $forJid): void
{
$group = "<group>{$name}</group>";
$item = "<item jid='{$forJid}'>{$group}</item>";
$query = "<query xmlns='jabber:iq:roster'>{$item}</query>";
$xml = "<iq type='set' id='{$this->uniqueId()}'>{$query}</iq>";
$this->socket->send($xml);
}
public function getServerVersion(): void
{
$query = "<query xmlns='jabber:iq:version'/>";
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
$this->socket->send($xml);
}
public function getServerFeatures(): void
{
$query = "<query xmlns='http://jabber.org/protocol/disco#info'></query>";
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
$this->socket->send($xml);
}
public function getServerTime(): void
{
$query = "<query xmlns='urn:xmpp:time'/>";
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
$this->socket->send($xml);
}
public function getFeatures(string $forJid): void
{
$query = "<query xmlns='http://jabber.org/protocol/disco#info'></query>";
$xml = "<iq type='get' to='{$forJid}'>{$query}</iq>";
$this->socket->send($xml);
}
public function ping(): void
{
$query = "<query xmlns='urn:xmpp:ping'/>";
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
$this->socket->send($xml);
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Xml\Stanzas;
class Message extends Stanza
{
public function send(string $body, string $to, string $type = "chat"): void
{
$xml = $this->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>{$body}</body>";
return "<message to='{$to}' type='{$type}'>{$bodyXml}</message>";
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Xml\Stanzas;
class Presence extends Stanza
{
public const PRIORITY_UPPER_BOUND = 127;
public const PRIORITY_LOWER_BOUND = -128;
public function subscribe(string $to): void
{
$this->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 = "<presence from='{$this->socket->getOptions()->bareJid()}' to='{$to}' type='{$type}'/>";
$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 = "<priority>{$this->limitPriority($value)}</priority>";
$xml = "<presence from='{$from}'>{$priority}</presence>";
$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;
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Xml\Stanzas;
use Pdahal\Xmpp\Socket;
use Pdahal\Xmpp\Xml\Xml;
abstract class Stanza
{
use Xml;
public function __construct(protected Socket $socket)
{
}
protected function uniqueId(): string
{
return uniqid();
}
protected function readResponseFile(): string|false
{
$logger = $this->socket->getOptions()->getLogger();
$responseFilePath = $logger->getFilePathFromResource($logger->log);
$responseFile = fopen($responseFilePath, 'r');
return fread($responseFile, filesize($responseFilePath));
}
}

123
src/Xml/Xml.php Normal file
View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp\Xml;
use Pdahal\Xmpp\Exceptions\StreamError;
trait Xml
{
/**
* Opening tag for starting a XMPP stream exchange.
*/
public static function openXmlStream(string $host): string
{
$xmlOpen = "<?xml version='1.0' encoding='UTF-8'?>";
$to = "to='{$host}'";
$stream = "xmlns:stream='http://etherx.jabber.org/streams'";
$client = "xmlns='jabber:client'";
$version = "version='1.0'";
return "{$xmlOpen}<stream:stream {$to} {$stream} {$client} {$version}>";
}
/**
* Closing tag for one XMPP stream session.
*/
public static function closeXmlStream(): string
{
return '</stream:stream>';
}
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("#<proceed xmlns=[\\'|\"]urn:ietf:params:xml:ns:xmpp-tls[\\'|\"]\\/>#", (string) $xml, $match);
return count($match) > 0;
}
public static function supportedAuthMethods($xml): array
{
preg_match_all('#<mechanism>(.*?)<\\/mechanism>#', (string) $xml, $match);
return count($match) < 1 ? [] : $match[1];
}
public static function roster(string $xml): array
{
preg_match_all("#<iq.*?type=[\\'|\"]result[\\'|\"]>(.*?)<\\/iq>#", $xml, $match);
return count($match) < 1 ? [] : $match[1];
}
/**
* @throws StreamError
*/
public static function checkForUnrecoverableErrors(string $response): void
{
preg_match_all('#<stream:error>(<(.*?) (.*?)\\/>)<\\/stream:error>#', $response, $streamErrors);
if ((!empty($streamErrors[0])) && count($streamErrors[2]) > 0) {
throw new StreamError($streamErrors[2][0]);
}
}
}

131
src/XmppClient.php Normal file
View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Pdahal\Xmpp;
use Pdahal\Xmpp\Exceptions\StreamError;
use Pdahal\Xmpp\Xml\Stanzas\Auth;
use Pdahal\Xmpp\Xml\Stanzas\Iq;
use Pdahal\Xmpp\Xml\Stanzas\Message;
use Pdahal\Xmpp\Xml\Stanzas\Presence;
use Pdahal\Xmpp\Xml\Xml;
class XmppClient
{
use Xml;
protected Socket $socket;
public Auth $auth;
public Iq $iq;
public Presence $presence;
public Message $message;
public function __construct(protected Options $options)
{
$this->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('<presence/>');
}
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();
}
}

31
tests/AuthPlainTest.php Normal file
View File

@ -0,0 +1,31 @@
<?php
use Pdahal\Xmpp\AuthTypes\Plain;
use Pdahal\Xmpp\Options;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class AuthPlainTest extends TestCase
{
public Plain $plainAuth;
public Options $optionsStub;
protected function setUp(): void
{
$this->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());
}
}

26
tests/DigestMD5Test.php Normal file
View File

@ -0,0 +1,26 @@
<?php
use Pdahal\Xmpp\AuthTypes\DigestMD5;
use Pdahal\Xmpp\Options;
use PHPUnit\Framework\TestCase;
class DigestMD5Test extends TestCase
{
public DigestMD5 $digestAuth;
public Options $optionsStub;
protected function setUp(): void
{
$this->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());
}
}

124
tests/OptionsTest.php Normal file
View File

@ -0,0 +1,124 @@
<?php
use Pdahal\Xmpp\Options;
use PHPUnit\Framework\TestCase;
use Psr\Log\InvalidArgumentException;
/**
* @internal
*
* @coversNothing
*/
class OptionsTest extends TestCase
{
protected $host;
protected $port;
protected $username;
protected $password;
public Options $options;
protected function setUp(): void
{
$this->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());
}
}

View File

@ -0,0 +1,70 @@
<?php
use Pdahal\Xmpp\Buffers\Response;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class ResponseBufferTest extends TestCase
{
public Response $buffer;
protected function setUp(): void
{
$this->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());
}
}

16
tests/XmlTest.php Normal file
View File

@ -0,0 +1,16 @@
<?php
use Pdahal\Xmpp\Xml\Xml;
use PHPUnit\Framework\TestCase;
class XmlTest extends TestCase
{
use Xml;
public $host = 'www.test.com';
public function testOpeningStream(): void
{
$expected = "<?xml version='1.0' encoding='UTF-8'?><stream:stream to='www.test.com' xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' version='1.0'>";
$this->assertEquals($expected, $this->openXmlStream($this->host));
}
}