NNTPService::__destruct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
1
<?php
2
3
namespace App\Services\NNTP;
4
5
use App\Models\Settings;
6
use App\Services\Tmux\Tmux;
7
use App\Services\YencService;
8
9
/*
10
 * Response codes not defined in Net_NNTP
11
 */
12
defined('NET_NNTP_PROTOCOL_RESPONSECODE_DISCONNECTING_FORCED') || define('NET_NNTP_PROTOCOL_RESPONSECODE_DISCONNECTING_FORCED', 400);
13
defined('NET_NNTP_PROTOCOL_RESPONSECODE_XGTITLE_GROUPS_UNAVAILABLE') || define('NET_NNTP_PROTOCOL_RESPONSECODE_XGTITLE_GROUPS_UNAVAILABLE', 481);
14
defined('NET_NNTP_PROTOCOL_RESPONSECODE_TLS_FAILED_NEGOTIATION') || define('NET_NNTP_PROTOCOL_RESPONSECODE_TLS_FAILED_NEGOTIATION', 580);
15
16
/**
17
 * NNTP Service for connecting to usenet, retrieving articles and article headers,
18
 * decoding yEnc articles, and decompressing article headers.
19
 *
20
 * This service wraps the Net_NNTP_Client class with enhanced functionality
21
 * and Laravel-friendly dependency injection.
22
 */
23
class NNTPService extends \Net_NNTP_Client
24
{
25
    protected bool $_debugBool;
26
27
    protected bool $_echo;
28
29
    /**
30
     * Does the server support XFeature GZip header compression?
31
     */
32
    protected bool $_compressionSupported = true;
33
34
    /**
35
     * Is header compression enabled for the session?
36
     */
37
    protected bool $_compressionEnabled = false;
38
39
    /**
40
     * Currently selected group.
41
     */
42
    protected string $_currentGroup = '';
43
44
    protected string $_currentPort = 'NNTP_PORT';
45
46
    /**
47
     * Address of the current NNTP server.
48
     */
49
    protected string $_currentServer = 'NNTP_SERVER';
50
51
    /**
52
     * Are we allowed to post to usenet?
53
     */
54
    protected bool $_postingAllowed = false;
55
56
    /**
57
     * How many times should we try to reconnect to the NNTP server?
58
     */
59
    protected int $_nntpRetries;
60
61
    /**
62
     * How many connections should we use on primary NNTP server.
63
     */
64
    protected string $_primaryNntpConnections;
65
66
    /**
67
     * How many connections should we use on alternate NNTP server.
68
     */
69
    protected string $_alternateNntpConnections;
70
71
    /**
72
     * How many connections do we use on primary NNTP server.
73
     */
74
    protected int $_primaryCurrentNntpConnections;
75
76
    /**
77
     * How many connections do we use on alternate NNTP server.
78
     */
79
    protected int $_alternateCurrentNntpConnections;
80
81
    /**
82
     * Seconds to wait for the blocking socket to timeout.
83
     */
84
    protected int $_socketTimeout = 120;
85
86
    protected Tmux $_tmux;
87
88
    /**
89
     * @var resource|null
90
     */
91
    protected $_socket = null;
92
93
    /**
94
     * Cached config values for performance.
95
     */
96
    protected string $_configServer;
97
98
    protected string $_configAlternateServer;
99
100
    protected int $_configPort;
101
102
    protected int $_configAlternatePort;
103
104
    protected bool $_configSsl;
105
106
    protected bool $_configAlternateSsl;
107
108
    protected string $_configUsername;
109
110
    protected string $_configPassword;
111
112
    protected string $_configAlternateUsername;
113
114
    protected string $_configAlternatePassword;
115
116
    protected int $_configSocketTimeout;
117
118
    protected int $_configAlternateSocketTimeout;
119
120
    protected bool $_configCompressedHeaders;
121
122
    /**
123
     * If on unix, hide yydecode CLI output.
124
     */
125
    protected string $_yEncSilence;
126
127
    /**
128
     * Path to temp yEnc input storage file.
129
     */
130
    protected string $_yEncTempInput;
131
132
    /**
133
     * Path to temp yEnc output storage file.
134
     */
135
    protected string $_yEncTempOutput;
136
137
    /**
138
     * YEnc encoding/decoding service.
139
     */
140
    protected YencService $_yencService;
141
142
    /**
143
     * Create a new NNTP service instance.
144
     */
145
    public function __construct(?Tmux $tmux = null, ?YencService $yencService = null)
146
    {
147
        parent::__construct();
148
149
        $this->_echo = config('nntmux.echocli');
150
        $this->_tmux = $tmux ?? new Tmux;
151
        $this->_yencService = $yencService ?? app(YencService::class);
152
        $this->_nntpRetries = Settings::settingValue('nntpretries') !== '' ? (int) Settings::settingValue('nntpretries') : 0 + 1;
153
154
        $this->initializeConfig();
155
    }
156
157
    /**
158
     * Initialize configuration values from Laravel config.
159
     */
160
    protected function initializeConfig(): void
161
    {
162
        $this->_configServer = config('nntmux_nntp.server');
163
        $this->_configAlternateServer = config('nntmux_nntp.alternate_server');
164
        $this->_configPort = (int) config('nntmux_nntp.port');
165
        $this->_configAlternatePort = (int) config('nntmux_nntp.alternate_server_port');
166
        $this->_configSsl = (bool) config('nntmux_nntp.ssl');
167
        $this->_configAlternateSsl = (bool) config('nntmux_nntp.alternate_server_ssl');
168
        $this->_configUsername = config('nntmux_nntp.username') ?? '';
169
        $this->_configPassword = config('nntmux_nntp.password') ?? '';
170
        $this->_configAlternateUsername = config('nntmux_nntp.alternate_server_username') ?? '';
171
        $this->_configAlternatePassword = config('nntmux_nntp.alternate_server_password') ?? '';
172
        $this->_configSocketTimeout = (int) (config('nntmux_nntp.socket_timeout') ?: $this->_socketTimeout);
173
        $this->_configAlternateSocketTimeout = (int) (config('nntmux_nntp.alternate_server_socket_timeout') ?: $this->_socketTimeout);
174
        $this->_configCompressedHeaders = (bool) config('nntmux_nntp.compressed_headers');
175
176
        $this->_currentPort = $this->_configPort;
177
        $this->_currentServer = $this->_configServer;
178
        $this->_primaryNntpConnections = config('nntmux_nntp.main_nntp_connections');
179
        $this->_alternateNntpConnections = config('nntmux_nntp.alternate_nntp_connections');
180
        $this->_selectedGroupSummary = null;
181
        $this->_overviewFormatCache = null;
182
    }
183
184
    /**
185
     * Destruct.
186
     * Close the NNTP connection if still connected.
187
     */
188
    public function __destruct()
189
    {
190
        $this->doQuit();
191
    }
192
193
    /**
194
     * Connect to a usenet server.
195
     *
196
     * @param  bool  $compression  Should we attempt to enable XFeature Gzip compression on this connection?
197
     * @param  bool  $alternate  Use the alternate NNTP connection.
198
     * @return mixed On success = (bool)   Did we successfully connect to the usenet?
199
     *
200
     * @throws \Exception
201
     *                    On failure = (object) PEAR_Error.
202
     */
203
    public function doConnect(bool $compression = true, bool $alternate = false): mixed
204
    {
205
        $primaryUSP = [
206
            'ip' => gethostbyname($this->_configServer),
207
            'port' => $this->_configPort,
208
        ];
209
        $alternateUSP = [
210
            'ip_a' => gethostbyname($this->_configAlternateServer),
211
            'port_a' => $this->_configAlternatePort,
212
        ];
213
        $primaryConnections = $this->_tmux->getUSPConnections('primary', $primaryUSP);
214
        $alternateConnections = $this->_tmux->getUSPConnections('alternate', $alternateUSP);
215
        if ($this->_isConnected() && (($alternate && $this->_currentServer === $this->_configAlternateServer && ($this->_primaryNntpConnections < $alternateConnections['alternate']['active'])) || (! $alternate && $this->_currentServer === $this->_configServer && ($this->_primaryNntpConnections < $primaryConnections['primary']['active'])))) {
216
            return true;
217
        }
218
219
        $this->doQuit();
220
221
        $ret = $connected = $cError = $aError = false;
222
223
        // Set variables to connect based on if we are using the alternate provider or not.
224
        if (! $alternate) {
225
            $sslEnabled = $this->_configSsl;
226
            $this->_currentServer = $this->_configServer;
227
            $this->_currentPort = $this->_configPort;
228
            $userName = $this->_configUsername;
229
            $password = $this->_configPassword;
230
            $socketTimeout = $this->_configSocketTimeout;
231
        } else {
232
            $sslEnabled = $this->_configAlternateSsl;
233
            $this->_currentServer = $this->_configAlternateServer;
234
            $this->_currentPort = $this->_configAlternatePort;
235
            $userName = $this->_configAlternateUsername;
236
            $password = $this->_configAlternatePassword;
237
            $socketTimeout = $this->_configAlternateSocketTimeout;
238
        }
239
240
        $enc = ($sslEnabled ? ' (ssl)' : ' (non-ssl)');
241
        $sslEnabled = ($sslEnabled ? 'tls' : false);
242
243
        // Try to connect until we run of out tries.
244
        $retries = $this->_nntpRetries;
245
        while (true) {
246
            $retries--;
247
            $authenticated = false;
248
249
            // If we are not connected, try to connect.
250
            if (! $connected) {
251
                $ret = $this->connect($this->_currentServer, $sslEnabled, $this->_currentPort, 5, $socketTimeout);
0 ignored issues
show
Bug introduced by
$this->_currentPort of type string is incompatible with the type integer|null expected by parameter $port of App\Services\NNTP\NNTPService::connect(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

251
                $ret = $this->connect($this->_currentServer, $sslEnabled, /** @scrutinizer ignore-type */ $this->_currentPort, 5, $socketTimeout);
Loading history...
252
            }
253
            // Check if we got an error while connecting.
254
            $cErr = self::isError($ret);
255
256
            // If no error, we are connected.
257
            if (! $cErr) {
258
                // Say that we are connected so we don't retry.
259
                $connected = true;
260
                // When there is no error it returns bool if we are allowed to post or not.
261
                $this->_postingAllowed = $ret;
262
            } elseif (! $cError) {
263
                $cError = $ret->getMessage();
264
            }
265
266
            // If error, try to connect again.
267
            if ($cErr && $retries > 0) {
268
                continue;
269
            }
270
271
            // If we have no more retries and could not connect, return an error.
272
            if ($retries === 0 && ! $connected) {
273
                $message =
274
                    'Cannot connect to server '.
275
                    $this->_currentServer.
276
                    $enc.
277
                    ': '.
278
                    $cError;
279
280
                return $this->throwError(cli()->error($message));
0 ignored issues
show
Bug introduced by
The method throwError() does not exist on App\Services\NNTP\NNTPService. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

280
                return $this->/** @scrutinizer ignore-call */ throwError(cli()->error($message));
Loading history...
281
            }
282
283
            // If we are connected, try to authenticate.
284
            if ($connected) {
285
                // If the username is empty it probably means the server does not require a username.
286
                if ($userName === '') {
287
                    $authenticated = true;
288
289
                    // Try to authenticate to usenet.
290
                } else {
291
                    $ret2 = $this->authenticate($userName, $password);
292
293
                    // Check if there was an error authenticating.
294
                    $aErr = self::isError($ret2);
295
296
                    // If there was no error, then we are authenticated.
297
                    if (! $aErr) {
298
                        $authenticated = true;
299
                    } elseif (! $aError) {
300
                        $aError = $ret2->getMessage();
301
                    }
302
303
                    // If error, try to authenticate again.
304
                    if ($aErr && $retries > 0) {
305
                        continue;
306
                    }
307
308
                    // If we ran out of retries, return an error.
309
                    if ($retries === 0 && ! $authenticated) {
310
                        $message =
311
                            'Cannot authenticate to server '.
312
                            $this->_currentServer.
313
                            $enc.
314
                            ' - '.
315
                            $userName.
316
                            ' ('.$aError.')';
317
318
                        return $this->throwError(cli()->error($message));
319
                    }
320
                }
321
            }
322
            // If we are connected and authenticated, try enabling compression if we have it enabled.
323
            if ($connected && $authenticated) {
324
                // Check if we should use compression on the connection.
325
                if (! $compression || ! $this->_configCompressedHeaders) {
326
                    $this->_compressionSupported = false;
327
                }
328
329
                return true;
330
            }
331
            // If we reached this point and have not connected after all retries, break out of the loop.
332
            if ($retries === 0) {
333
                break;
334
            }
335
336
            // Sleep .4 seconds between retries.
337
            usleep(400000);
338
        }
339
        // If we somehow got out of the loop, return an error.
340
        $message = 'Unable to connect to '.$this->_currentServer.$enc;
341
342
        return $this->throwError(cli()->error($message));
343
    }
344
345
    /**
346
     * Disconnect from the current NNTP server.
347
     *
348
     * @param  bool  $force  Force quit even if not connected?
349
     * @return mixed On success : (bool)   Did we successfully disconnect from usenet?
350
     *               On Failure : (object) PEAR_Error.
351
     */
352
    public function doQuit(bool $force = false): mixed
353
    {
354
        $this->_resetProperties();
355
356
        // Check if we are connected to usenet.
357
        if ($force || $this->_isConnected(false)) {
358
            // Disconnect from usenet.
359
            return $this->disconnect();
360
        }
361
362
        return true;
363
    }
364
365
    /**
366
     * Reset some properties when disconnecting from usenet.
367
     */
368
    protected function _resetProperties(): void
369
    {
370
        $this->_compressionEnabled = false;
371
        $this->_compressionSupported = true;
372
        $this->_currentGroup = '';
373
        $this->_postingAllowed = false;
374
        $this->_selectedGroupSummary = null;
375
        $this->_overviewFormatCache = null;
376
        $this->_socket = null;
377
    }
378
379
    /**
380
     * Attempt to enable compression if the admin enabled the site setting.
381
     *
382
     * @note   This can be used to enable compression if the server was connected without compression.
383
     *
384
     * @throws \Exception
385
     */
386
    public function enableCompression(): void
387
    {
388
        if (! $this->_configCompressedHeaders) {
389
            return;
390
        }
391
        $this->_enableCompression();
392
    }
393
394
    /**
395
     * @param  string  $group  Name of the group to select.
396
     * @param  mixed  $articles  (optional) experimental! When true the article numbers is returned in 'articles'.
397
     * @param  bool  $force  Force a refresh to get updated data from the usenet server.
398
     * @return mixed On success : (array)  Group information.
399
     *
400
     * @throws \Exception
401
     *                    On failure : (object) PEAR_Error.
402
     */
403
    public function selectGroup(string $group, mixed $articles = false, bool $force = false): mixed
404
    {
405
        $connected = $this->_checkConnection(false);
406
        if ($connected !== true) {
407
            return $connected;
408
        }
409
410
        // Check if the current selected group is the same, or if we have not selected a group or if a fresh summary is wanted.
411
        if ($force || $this->_currentGroup !== $group || $this->_selectedGroupSummary === null) {
412
            $this->_currentGroup = $group;
413
414
            return parent::selectGroup($group, $articles);
415
        }
416
417
        return $this->_selectedGroupSummary;
418
    }
419
420
    /**
421
     * Fetch an overview of article(s) in the currently selected group.
422
     *
423
     * @return mixed On success : (array)  Multidimensional array with article headers.
424
     *
425
     * @throws \Exception
426
     *                    On failure : (object) PEAR_Error.
427
     */
428
    public function getOverview($range = null, $names = true, $forceNames = true): mixed
429
    {
430
        $connected = $this->_checkConnection();
431
        if ($connected !== true) {
432
            return $connected;
433
        }
434
435
        // Enabled header compression if not enabled.
436
        $this->_enableCompression();
437
438
        return parent::getOverview($range, $names, $forceNames);
439
    }
440
441
    /**
442
     * Pass a XOVER command to the NNTP provider, return array of articles using the overview format as array keys.
443
     *
444
     * @note This is a faster implementation of getOverview.
445
     *
446
     * Example successful return:
447
     *    array(9) {
448
     *        'Number'     => string(9)  "679871775"
449
     *        'Subject'    => string(18) "This is an example"
450
     *        'From'       => string(19) "[email protected]"
451
     *        'Date'       => string(24) "26 Jun 2014 13:08:22 GMT"
452
     *        'Message-ID' => string(57) "<part1of1.uS*yYxQvtAYt$5t&wmE%[email protected]>"
453
     *        'References' => string(0)  ""
454
     *        'Bytes'      => string(3)  "123"
455
     *        'Lines'      => string(1)  "9"
456
     *        'Xref'       => string(66) "e alt.test:679871775"
457
     *    }
458
     *
459
     * @param  string  $range  Range of articles to get the overview for. Examples follow:
460
     *                         Single article number:         "679871775"
461
     *                         Range of article numbers:      "679871775-679999999"
462
     *                         All newer than article number: "679871775-"
463
     *                         All older than article number: "-679871775"
464
     *                         Message-ID:                    "<part1of1.uS*yYxQvtAYt$5t&wmE%[email protected]>"
465
     * @return array|string|NNTPService Multi-dimensional Array of headers on success, PEAR object on failure.
466
     *
467
     * @throws \Exception
468
     */
469
    public function getXOVER(string $range): array|string|NNTPService
470
    {
471
        // Check if we are still connected.
472
        $connected = $this->_checkConnection();
473
        if ($connected !== true) {
474
            return $connected;
475
        }
476
477
        // Enabled header compression if not enabled.
478
        $this->_enableCompression();
479
480
        // Send XOVER command to NNTP with wanted articles.
481
        $response = $this->_sendCommand('XOVER '.$range);
482
        if (self::isError($response)) {
483
            return $response;
484
        }
485
486
        // Verify the NNTP server got the right command, get the headers data.
487
        if ($response === NET_NNTP_PROTOCOL_RESPONSECODE_OVERVIEW_FOLLOWS) {
488
            $data = $this->_getTextResponse();
489
            if (self::isError($data)) {
490
                return $data;
491
            }
492
        } else {
493
            return $this->_handleErrorResponse($response);
494
        }
495
496
        // Fetch the header overview format (for setting the array keys on the return array).
497
        if ($this->_overviewFormatCache !== null && isset($this->_overviewFormatCache['Xref'])) {
498
            $overview = $this->_overviewFormatCache;
499
        } else {
500
            $overview = $this->getOverviewFormat(false, true);
501
            if (self::isError($overview)) {
502
                return $overview;
503
            }
504
            $this->_overviewFormatCache = $overview;
505
        }
506
507
        // Pre-compute keys array and Xref position for faster processing
508
        $keys = array_merge(['Number'], array_keys($overview));
509
        $keyCount = \count($keys);
510
        $xrefIndex = array_search('Xref', $keys, true);
511
512
        // Loop over strings of headers.
513
        foreach ($data as $key => $header) {
514
            // Split the individual headers by tab.
515
            $parts = explode("\t", $header);
516
517
            // Make sure it's not empty.
518
            if ($parts === false || empty($parts)) {
519
                continue;
520
            }
521
522
            // Build header array using pre-computed keys
523
            $headerArray = [];
524
            $partCount = \count($parts);
525
526
            for ($i = 0; $i < $keyCount && $i < $partCount; $i++) {
527
                $value = $parts[$i];
528
                // Strip "Xref: " prefix if this is the Xref field
529
                if ($i === $xrefIndex && isset($value[5])) {
530
                    $value = substr($value, 6);
531
                }
532
                $headerArray[$keys[$i]] = $value;
533
            }
534
535
            // Add the individual header array back to the return array.
536
            $data[$key] = $headerArray;
537
        }
538
539
        // Return the array of headers.
540
        return $data;
541
    }
542
543
    /**
544
     * Fetch valid groups.
545
     *
546
     * Returns a list of valid groups (that the client is permitted to select) and associated information.
547
     *
548
     * @param  mixed  $wildMat  (optional) http://tools.ietf.org/html/rfc3977#section-4
549
     * @return array|string Pear error on failure, array with groups on success.
550
     *
551
     * @throws \Exception
552
     */
553
    public function getGroups(mixed $wildMat = null): mixed
554
    {
555
        // Enabled header compression if not enabled.
556
        $this->_enableCompression();
557
558
        return parent::getGroups($wildMat);
559
    }
560
561
    /**
562
     * Download multiple article bodies and string them together.
563
     *
564
     * @param  string  $groupName  The name of the group the articles are in.
565
     * @param  mixed  $identifiers  (string) Message-ID.
566
     *                              (int)    Article number.
567
     *                              (array)  Article numbers or Message-ID's (can contain both in the same array)
568
     * @param  bool  $alternate  Use the alternate NNTP provider?
569
     * @return mixed On success : (string) The article bodies.
570
     *
571
     * @throws \Exception
572
     *                    On failure : (object) PEAR_Error.
573
     */
574
    public function getMessages(string $groupName, mixed $identifiers, bool $alternate = false): mixed
575
    {
576
        $connected = $this->_checkConnection();
577
        if ($connected !== true) {
578
            return $connected;
579
        }
580
581
        // String to hold all the bodies.
582
        $body = '';
583
584
        $aConnected = false;
585
        $nntp = ($alternate ? new self : null);
586
587
        // Check if the msgIds are in an array.
588
        if (\is_array($identifiers)) {
589
            $loops = $messageSize = 0;
590
591
            // Loop over the message-ID's or article numbers.
592
            foreach ($identifiers as $wanted) {
593
                /* This is to attempt to prevent string size overflow.
594
                 * We get the size of 1 body in bytes, we increment the loop on every loop,
595
                 * then we multiply the # of loops by the first size we got and check if it
596
                 * exceeds 1.7 billion bytes (less than 2GB to give us headroom).
597
                 * If we exceed, return the data.
598
                 * If we don't do this, these errors are fatal.
599
                 */
600
                if ((++$loops * $messageSize) >= 1700000000) {
601
                    return $body;
602
                }
603
604
                // Download the body.
605
                $message = $this->_getMessage($groupName, $wanted);
606
607
                // Append the body to $body.
608
                if (! self::isError($message)) {
609
                    $body .= $message;
610
611
                    if ($messageSize === 0) {
612
                        $messageSize = \strlen($message);
613
                    }
614
615
                    // If there is an error try the alternate provider or return the PEAR error.
616
                } elseif ($alternate) {
617
                    if (! $aConnected) {
618
                        // Check if the current connected server is the alternate or not.
619
                        $aConnected = $this->_currentServer === $this->_configServer
620
                            ? $nntp->doConnect($this->_configCompressedHeaders, true)
0 ignored issues
show
Bug introduced by
The method doConnect() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

620
                            ? $nntp->/** @scrutinizer ignore-call */ doConnect($this->_configCompressedHeaders, true)

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
621
                            : $nntp->doConnect();
622
                    }
623
                    // If we connected successfully to usenet try to download the article body.
624
                    if ($aConnected === true) {
625
                        $newBody = $nntp->_getMessage($groupName, $wanted);
626
                        // Check if we got an error.
627
                        if ($nntp->isError($newBody)) {
628
                            if ($aConnected) {
629
                                $nntp->doQuit();
630
                            }
631
                            // If we got some data, return it.
632
                            if ($body !== '') {
633
                                return $body;
634
                            }
635
636
                            // Return the error.
637
                            return $newBody;
638
                        }
639
                        // Append the alternate body to the main body.
640
                        $body .= $newBody;
641
                    }
642
                } else {
643
                    // If we got some data, return it.
644
                    if ($body !== '') {
645
                        return $body;
646
                    }
647
648
                    return $message;
649
                }
650
            }
651
652
            // If it's a string check if it's a valid message-ID.
653
        } elseif (\is_string($identifiers) || is_numeric($identifiers)) {
654
            $body = $this->_getMessage($groupName, $identifiers);
655
            if ($alternate && self::isError($body)) {
656
                $nntp->doConnect($this->_configCompressedHeaders, true);
657
                $body = $nntp->_getMessage($groupName, $identifiers);
658
                $aConnected = true;
659
            }
660
661
            // Else return an error.
662
        } else {
663
            $message = 'Wrong Identifier type, array, int or string accepted. This type of var was passed: '.gettype($identifiers);
664
665
            return $this->throwError(cli()->error($message));
666
        }
667
668
        if ($aConnected === true) {
669
            $nntp->doQuit();
670
        }
671
672
        return $body;
673
    }
674
675
    /**
676
     * Download multiple article bodies by Message-ID only (no group selection), concatenating them.
677
     * Falls back to alternate provider if enabled. Message-IDs are yEnc decoded.
678
     *
679
     * @param  mixed  $identifiers  string|array Message-ID(s) (with or without < >)
680
     * @param  bool  $alternate  Use alternate NNTP server if primary fails for any ID.
681
     * @return mixed string concatenated bodies on success, PEAR_Error object on total failure.
682
     *
683
     * @throws \Exception
684
     */
685
    public function getMessagesByMessageID(mixed $identifiers, bool $alternate = false): mixed
686
    {
687
        $connected = $this->_checkConnection(false); // no need to reselect group
688
        if ($connected !== true) {
689
            return $connected; // PEAR error passthrough
690
        }
691
692
        $body = '';
693
        $aConnected = false;
694
        $alt = ($alternate ? new self : null);
695
696
        // Normalise to array for loop processing
697
        $ids = is_array($identifiers) ? $identifiers : [$identifiers];
698
699
        $loops = 0;
700
        $messageSize = 0;
701
        foreach ($ids as $id) {
702
            if ((++$loops * $messageSize) >= 1700000000) { // prevent huge string growth
703
                return $body;
704
            }
705
            $msg = $this->_getMessageByMessageID($id);
706
            if (! self::isError($msg)) {
707
                $body .= $msg;
708
                if ($messageSize === 0) {
709
                    $messageSize = strlen($msg);
710
                }
711
712
                continue;
713
            }
714
            // Primary failed, try alternate if requested
715
            if ($alternate) {
716
                if (! $aConnected) {
717
                    $aConnected = $this->_currentServer === $this->_configServer
718
                        ? $alt->doConnect($this->_configCompressedHeaders, true)
0 ignored issues
show
Bug introduced by
The method doConnect() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

718
                        ? $alt->/** @scrutinizer ignore-call */ doConnect($this->_configCompressedHeaders, true)

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
719
                        : $alt->doConnect();
720
                }
721
                if ($aConnected === true) {
722
                    $altMsg = $alt->_getMessageByMessageID($id);
723
                    if ($alt->isError($altMsg)) {
724
                        if ($aConnected) {
725
                            $alt->doQuit();
726
                        }
727
728
                        return $body !== '' ? $body : $altMsg; // return what we have or error
729
                    }
730
                    $body .= $altMsg;
731
                } else { // alternate connect failed
732
                    return $body !== '' ? $body : $msg; // return collected or original error
733
                }
734
            } else { // no alternate
735
                return $body !== '' ? $body : $msg;
736
            }
737
        }
738
739
        if ($aConnected === true) {
740
            $alt->doQuit();
741
        }
742
743
        return $body;
744
    }
745
746
    /**
747
     * Internal: fetch single article body by Message-ID (yEnc decoded) without selecting a group.
748
     * Accepts article numbers but these require a group; will return error if numeric passed.
749
     *
750
     * @param  mixed  $identifier  Message-ID or article number.
751
     * @return mixed string body on success, PEAR_Error on failure.
752
     *
753
     * @throws \Exception
754
     */
755
    protected function _getMessageByMessageID(mixed $identifier): mixed
756
    {
757
        // If numeric we cannot safely fetch without group context – delegate to existing path via error.
758
        if (is_numeric($identifier)) {
759
            return $this->throwError('Numeric article number requires group selection');
760
        }
761
        $id = $this->_formatMessageID($identifier);
762
        $response = $this->_sendCommand('BODY '.$id);
0 ignored issues
show
Bug introduced by
Are you sure $id of type false|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

762
        $response = $this->_sendCommand('BODY './** @scrutinizer ignore-type */ $id);
Loading history...
763
        if (self::isError($response)) {
764
            return $response;
765
        }
766
        if ($response !== NET_NNTP_PROTOCOL_RESPONSECODE_BODY_FOLLOWS) {
767
            return $this->_handleErrorResponse($response);
768
        }
769
770
        // Use array to accumulate lines (faster than string concatenation)
771
        $bodyParts = [];
772
        $socket = $this->_socket;
773
774
        while (! feof($socket)) {
775
            $line = fgets($socket, 8192);
776
            if ($line === false) {
777
                return $this->throwError('Failed to read line from socket.', null);
778
            }
779
            if ($line === ".\r\n") {
780
                $body = implode('', $bodyParts);
781
782
                return $this->_yencService->decodeIgnore($body);
783
            }
784
            if ($line[0] === '.' && isset($line[1]) && $line[1] === '.') {
785
                $line = substr($line, 1);
786
            }
787
            $bodyParts[] = $line;
788
        }
789
790
        return $this->throwError('End of stream! Connection lost?', null);
791
    }
792
793
    /**
794
     * Restart the NNTP connection if an error occurs in the selectGroup
795
     * function, if it does not restart display the error.
796
     *
797
     * @param  NNTPService  $nntp  Instance of class NNTPService.
798
     * @param  string  $group  Name of the group.
799
     * @param  bool  $comp  Use compression or not?
800
     * @return mixed On success : (array)  The group summary.
801
     *
802
     * @throws \Exception
803
     *                    On Failure : (object) PEAR_Error.
804
     */
805
    public function dataError(NNTPService $nntp, string $group, bool $comp = true): mixed
806
    {
807
        // Disconnect.
808
        $nntp->doQuit();
809
        // Try reconnecting. This uses another round of max retries.
810
        if ($nntp->doConnect($comp) !== true) {
811
            return $this->throwError('Unable to reconnect to usenet!');
812
        }
813
814
        // Try re-selecting the group.
815
        $data = $nntp->selectGroup($group);
816
        if (self::isError($data)) {
817
            $message = "Code {$data->code}: {$data->message}\nSkipping group: {$group}";
818
819
            if ($this->_echo) {
820
                cli()->error($message);
821
            }
822
            $nntp->doQuit();
823
        }
824
825
        return $data;
826
    }
827
828
    /**
829
     * Split a string into lines of 510 chars ending with \r\n.
830
     * Usenet limits lines to 512 chars, with \r\n that leaves us 510.
831
     *
832
     * @param  string  $string  The string to split.
833
     * @param  bool  $compress  Compress the string with gzip?
834
     * @return string The split string.
835
     */
836
    protected function _splitLines(string $string, bool $compress = false): string
837
    {
838
        // Check if the length is longer than 510 chars.
839
        if (\strlen($string) > 510) {
840
            // If it is, split it @ 510 and terminate with \r\n.
841
            $string = chunk_split($string, 510, "\r\n");
842
        }
843
844
        // Compress the string if requested.
845
        return $compress ? gzdeflate($string, 4) : $string;
846
    }
847
848
    /**
849
     * Try to see if the NNTP server implements XFeature GZip Compression,
850
     * change the compression bool object if so.
851
     *
852
     * @param  bool  $secondTry  This is only used if enabling compression fails, the function will call itself to retry.
853
     * @return mixed On success : (bool)   True:  The server understood and compression is enabled.
854
     *               (bool)   False: The server did not understand, compression is not enabled.
855
     *               On failure : (object) PEAR_Error.
856
     *
857
     * @throws \Exception
858
     */
859
    protected function _enableCompression(bool $secondTry = false): mixed
860
    {
861
        if ($this->_compressionEnabled) {
862
            return true;
863
        }
864
        if (! $this->_compressionSupported) {
865
            return false;
866
        }
867
868
        // Send this command to the usenet server.
869
        $response = $this->_sendCommand('XFEATURE COMPRESS GZIP');
870
871
        // Check if it's good.
872
        if (self::isError($response)) {
873
            $this->_compressionSupported = false;
874
875
            return $response;
876
        }
877
        if ($response !== 290) {
878
            if (! $secondTry) {
879
                // Retry.
880
                $this->cmdQuit();
881
                if ($this->_checkConnection()) {
882
                    return $this->_enableCompression(true);
883
                }
884
            }
885
            $msg = "Sent 'XFEATURE COMPRESS GZIP' to server, got '$response: ".$this->_currentStatusResponse()."'";
0 ignored issues
show
Unused Code introduced by
The assignment to $msg is dead and can be removed.
Loading history...
886
887
            $this->_compressionSupported = false;
888
889
            return false;
890
        }
891
892
        $this->_compressionEnabled = true;
893
        $this->_compressionSupported = true;
894
895
        return true;
896
    }
897
898
    /**
899
     * Override PEAR NNTP's function to use our _getXFeatureTextResponse instead
900
     * of their _getTextResponse function since it is incompatible at decoding
901
     * headers when XFeature GZip compression is enabled server side.
902
     *
903
     * @return \App\Services\NNTP\NNTPService|array|string Our overridden function when compression is enabled.
904
     *                                                     parent  Parent function when no compression.
905
     */
906
    public function _getTextResponse(): NNTPService|array|string
907
    {
908
        if ($this->_compressionEnabled &&
909
            isset($this->_currentStatusResponse[1]) &&
910
            stripos($this->_currentStatusResponse[1], 'COMPRESS=GZIP') !== false) {
911
            return $this->_getXFeatureTextResponse();
912
        }
913
914
        return parent::_getTextResponse();
915
    }
916
917
    /**
918
     * Loop over the compressed data when XFeature GZip Compress is turned on,
919
     * string the data until we find a indicator
920
     * (period, carriage feed, line return ;; .\r\n), decompress the data,
921
     * split the data (bunch of headers in a string) into an array, finally
922
     * return the array.
923
     *
924
     * Have we failed to decompress the data, was there a
925
     * problem downloading the data, etc..
926
     *
927
     * @return array|string On success : (array)  The headers.
928
     *                      On failure : (object) PEAR_Error.
929
     *                      On decompress failure: (string) error message
930
     */
931
    protected function &_getXFeatureTextResponse(): array|string
932
    {
933
        $possibleTerm = false;
934
        // Use array accumulation for better performance with large data
935
        $dataParts = [];
936
        $socket = $this->_socket;
937
938
        while (! feof($socket)) {
939
            // Did we find a possible ending ? (.\r\n)
940
            if ($possibleTerm) {
941
                // Use stream_select for more efficient socket polling
942
                $read = [$socket];
943
                $write = $except = null;
944
945
                // Check if data is available with a short timeout (5ms)
946
                $ready = @stream_select($read, $write, $except, 0, 5000);
947
948
                if ($ready > 0) {
949
                    // Data available, read it
950
                    stream_set_blocking($socket, false);
951
                    $buffer = fgets($socket, 16384);
952
                    stream_set_blocking($socket, true);
953
                } else {
954
                    $buffer = '';
955
                }
956
957
                // If the buffer was really empty, then we know $possibleTerm was the real ending.
958
                if ($buffer === '' || $buffer === false) {
959
                    // Join all parts and remove .\r\n from end, decompress data.
960
                    $data = implode('', $dataParts);
961
                    $deComp = @gzuncompress(substr($data, 0, -3));
962
963
                    if (! empty($deComp)) {
964
                        $bytesReceived = \strlen($data);
965
                        if ($this->_echo && $bytesReceived > 10240) {
966
                            cli()->primaryOver(
967
                                'Received '.round($bytesReceived / 1024).
968
                                'KB from group ('.$this->group().').'
969
                            );
970
                        }
971
972
                        // Split the string of headers into an array of individual headers, then return it.
973
                        $deComp = explode("\r\n", trim($deComp));
974
975
                        return $deComp;
976
                    }
977
                    $message = 'Decompression of OVER headers failed.';
978
979
                    return $this->throwError(cli()->error($message), 1000);
980
                }
981
                // The buffer was not empty, so we know this was not the real ending, so reset $possibleTerm.
982
                $possibleTerm = false;
983
                $dataParts[] = $buffer;
984
            } else {
985
                // Get data from the stream with larger buffer.
986
                $buffer = fgets($socket, 16384);
987
            }
988
989
            // If we got no data at all try one more time to pull data.
990
            if (empty($buffer)) {
991
                usleep(5000);
992
                $buffer = fgets($socket, 16384);
993
994
                // If we got nothing again, return error.
995
                if (empty($buffer)) {
996
                    $message = 'Error fetching data from usenet server while downloading OVER headers.';
997
998
                    return $this->throwError(cli()->error($message), 1000);
999
                }
1000
            }
1001
1002
            // Append current buffer to parts array.
1003
            $dataParts[] = $buffer;
1004
1005
            // Check if we have the ending (.\r\n) - check last 3 chars directly
1006
            $bufLen = \strlen($buffer);
1007
            if ($bufLen >= 3 && $buffer[$bufLen - 3] === '.' && $buffer[$bufLen - 2] === "\r" && $buffer[$bufLen - 1] === "\n") {
1008
                // We have a possible ending, next loop check if it is.
1009
                $possibleTerm = true;
1010
            }
1011
        }
1012
1013
        $message = 'Unspecified error while downloading OVER headers.';
1014
1015
        return $this->throwError(cli()->error($message), 1000);
1016
    }
1017
1018
    /**
1019
     * Check if the Message-ID has the required opening and closing brackets.
1020
     *
1021
     * @param  string  $messageID  The Message-ID with or without brackets.
1022
     * @return string|false Message-ID with brackets or false if empty.
1023
     */
1024
    protected function _formatMessageID(string $messageID): string|false
1025
    {
1026
        $messageID = (string) $messageID;
1027
        if ($messageID === '') {
1028
            return false;
1029
        }
1030
1031
        // Check if the first char is <, if not add it.
1032
        if ($messageID[0] !== '<') {
1033
            $messageID = ('<'.$messageID);
1034
        }
1035
1036
        // Check if the last char is >, if not add it.
1037
        if (! str_ends_with($messageID, '>')) {
1038
            $messageID .= '>';
1039
        }
1040
1041
        return $messageID;
1042
    }
1043
1044
    /**
1045
     * Download an article body (an article without the header).
1046
     *
1047
     * @return mixed|object|string
1048
     *
1049
     * @throws \Exception
1050
     */
1051
    protected function _getMessage(string $groupName, mixed $identifier): mixed
1052
    {
1053
        // Make sure the requested group is already selected, if not select it.
1054
        if ($this->group() !== $groupName) {
1055
            // Select the group.
1056
            $summary = $this->selectGroup($groupName);
1057
            // If there was an error selecting the group, return a PEAR error object.
1058
            if (self::isError($summary)) {
1059
                return $summary;
1060
            }
1061
        }
1062
1063
        // Check if this is an article number or message-id.
1064
        if (! is_numeric($identifier)) {
1065
            // It's a message-id so check if it has the triangular brackets.
1066
            $identifier = $this->_formatMessageID($identifier);
1067
        }
1068
1069
        // Tell the news server we want the body of an article.
1070
        $response = $this->_sendCommand('BODY '.$identifier);
1071
        if (self::isError($response)) {
1072
            return $response;
1073
        }
1074
1075
        if ($response === NET_NNTP_PROTOCOL_RESPONSECODE_BODY_FOLLOWS) {
1076
            // Use array to accumulate lines (faster than string concatenation for many appends)
1077
            $bodyParts = [];
1078
            $socket = $this->_socket;
1079
1080
            // Continue until connection is lost
1081
            while (! feof($socket)) {
1082
                // Retrieve and append up to 8192 characters from the server (larger buffer = fewer syscalls)
1083
                $line = fgets($socket, 8192);
1084
1085
                // If the socket is empty/ an error occurs, false is returned.
1086
                if ($line === false) {
1087
                    return $this->throwError('Failed to read line from socket.', null);
1088
                }
1089
1090
                // Check if the line terminates the text response.
1091
                if ($line === ".\r\n") {
1092
                    // Join all parts and attempt to yEnc decode
1093
                    $body = implode('', $bodyParts);
1094
1095
                    return $this->_yencService->decodeIgnore($body);
1096
                }
1097
1098
                // Check for line that starts with double period, remove one.
1099
                if ($line[0] === '.' && isset($line[1]) && $line[1] === '.') {
1100
                    $line = substr($line, 1);
1101
                }
1102
1103
                // Add the line to the array
1104
                $bodyParts[] = $line;
1105
            }
1106
1107
            return $this->throwError('End of stream! Connection lost?', null);
1108
        }
1109
1110
        return $this->_handleErrorResponse($response);
1111
    }
1112
1113
    /**
1114
     * Check if we are still connected. Reconnect if not.
1115
     *
1116
     * @param  bool  $reSelectGroup  Select back the group after connecting?
1117
     * @return mixed On success: (bool)   True;
1118
     *
1119
     * @throws \Exception
1120
     *                    On failure: (object) PEAR_Error
1121
     */
1122
    protected function _checkConnection(bool $reSelectGroup = true): mixed
1123
    {
1124
        $currentGroup = $this->_currentGroup;
1125
        // Check if we are connected.
1126
        if (parent::_isConnected()) {
1127
            $retVal = true;
1128
        } else {
1129
            switch ($this->_currentServer) {
1130
                case $this->_configServer:
1131
                    if (\is_resource($this->_socket)) {
1132
                        $this->doQuit(true);
1133
                    }
1134
                    $retVal = $this->doConnect();
1135
                    break;
1136
                case $this->_configAlternateServer:
1137
                    if (\is_resource($this->_socket)) {
1138
                        $this->doQuit(true);
1139
                    }
1140
                    $retVal = $this->doConnect(true, true);
1141
                    break;
1142
                default:
1143
                    $retVal = $this->throwError('Wrong server constant used in NNTP checkConnection()!');
1144
            }
1145
1146
            if ($retVal === true && $reSelectGroup) {
1147
                $group = $this->selectGroup($currentGroup);
1148
                if (self::isError($group)) {
1149
                    $retVal = $group;
1150
                }
1151
            }
1152
        }
1153
1154
        return $retVal;
1155
    }
1156
1157
    /**
1158
     * Verify NNTP error code and return PEAR error.
1159
     *
1160
     * @param  int  $response  NET_NNTP Response code
1161
     * @return object PEAR error
1162
     */
1163
    protected function _handleErrorResponse(int $response): object
1164
    {
1165
        switch ($response) {
1166
            // 381, RFC2980: 'More authentication information required'
1167
            case NET_NNTP_PROTOCOL_RESPONSECODE_AUTHENTICATION_CONTINUE:
1168
                return $this->throwError('More authentication information required', $response, $this->_currentStatusResponse());
1169
                // 400, RFC977: 'Service discontinued'
1170
            case NET_NNTP_PROTOCOL_RESPONSECODE_DISCONNECTING_FORCED:
1171
                return $this->throwError('Server refused connection', $response, $this->_currentStatusResponse());
1172
                // 411, RFC977: 'no such news group'
1173
            case NET_NNTP_PROTOCOL_RESPONSECODE_NO_SUCH_GROUP:
1174
                return $this->throwError('No such news group on server', $response, $this->_currentStatusResponse());
1175
                // 412, RFC2980: 'No news group current selected'
1176
            case NET_NNTP_PROTOCOL_RESPONSECODE_NO_GROUP_SELECTED:
1177
                return $this->throwError('No news group current selected', $response, $this->_currentStatusResponse());
1178
                // 420, RFC2980: 'Current article number is invalid'
1179
            case NET_NNTP_PROTOCOL_RESPONSECODE_NO_ARTICLE_SELECTED:
1180
                return $this->throwError('Current article number is invalid', $response, $this->_currentStatusResponse());
1181
                // 421, RFC977: 'no next article in this group'
1182
            case NET_NNTP_PROTOCOL_RESPONSECODE_NO_NEXT_ARTICLE:
1183
                return $this->throwError('No next article in this group', $response, $this->_currentStatusResponse());
1184
                // 422, RFC977: 'no previous article in this group'
1185
            case NET_NNTP_PROTOCOL_RESPONSECODE_NO_PREVIOUS_ARTICLE:
1186
                return $this->throwError('No previous article in this group', $response, $this->_currentStatusResponse());
1187
                // 423, RFC977: 'No such article number in this group'
1188
            case NET_NNTP_PROTOCOL_RESPONSECODE_NO_SUCH_ARTICLE_NUMBER:
1189
                return $this->throwError('No such article number in this group', $response, $this->_currentStatusResponse());
1190
                // 430, RFC977: 'No such article found'
1191
            case NET_NNTP_PROTOCOL_RESPONSECODE_NO_SUCH_ARTICLE_ID:
1192
                return $this->throwError('No such article found', $response, $this->_currentStatusResponse());
1193
                // 435, RFC977: 'Article not wanted'
1194
            case NET_NNTP_PROTOCOL_RESPONSECODE_TRANSFER_UNWANTED:
1195
                return $this->throwError('Article not wanted', $response, $this->_currentStatusResponse());
1196
                // 436, RFC977: 'Transfer failed - try again later'
1197
            case NET_NNTP_PROTOCOL_RESPONSECODE_TRANSFER_FAILURE:
1198
                return $this->throwError('Transfer failed - try again later', $response, $this->_currentStatusResponse());
1199
                // 437, RFC977: 'Article rejected - do not try again'
1200
            case NET_NNTP_PROTOCOL_RESPONSECODE_TRANSFER_REJECTED:
1201
                return $this->throwError('Article rejected - do not try again', $response, $this->_currentStatusResponse());
1202
                // 440, RFC977: 'posting not allowed'
1203
            case NET_NNTP_PROTOCOL_RESPONSECODE_POSTING_PROHIBITED:
1204
                return $this->throwError('Posting not allowed', $response, $this->_currentStatusResponse());
1205
                // 441, RFC977: 'posting failed'
1206
            case NET_NNTP_PROTOCOL_RESPONSECODE_POSTING_FAILURE:
1207
                return $this->throwError('Posting failed', $response, $this->_currentStatusResponse());
1208
                // 481, RFC2980: 'Groups and descriptions unavailable'
1209
            case NET_NNTP_PROTOCOL_RESPONSECODE_XGTITLE_GROUPS_UNAVAILABLE:
1210
                return $this->throwError('Groups and descriptions unavailable', $response, $this->_currentStatusResponse());
1211
                // 482, RFC2980: 'Authentication rejected'
1212
            case NET_NNTP_PROTOCOL_RESPONSECODE_AUTHENTICATION_REJECTED:
1213
                return $this->throwError('Authentication rejected', $response, $this->_currentStatusResponse());
1214
                // 500, RFC977: 'Command not recognized'
1215
            case NET_NNTP_PROTOCOL_RESPONSECODE_UNKNOWN_COMMAND:
1216
                return $this->throwError('Command not recognized', $response, $this->_currentStatusResponse());
1217
                // 501, RFC977: 'Command syntax error'
1218
            case NET_NNTP_PROTOCOL_RESPONSECODE_SYNTAX_ERROR:
1219
                return $this->throwError('Command syntax error', $response, $this->_currentStatusResponse());
1220
                // 502, RFC2980: 'No permission'
1221
            case NET_NNTP_PROTOCOL_RESPONSECODE_NOT_PERMITTED:
1222
                return $this->throwError('No permission', $response, $this->_currentStatusResponse());
1223
                // 503, RFC2980: 'Program fault - command not performed'
1224
            case NET_NNTP_PROTOCOL_RESPONSECODE_NOT_SUPPORTED:
1225
                return $this->throwError('Internal server error, function not performed', $response, $this->_currentStatusResponse());
1226
                // RFC4642: 'Can not initiate TLS negotiation'
1227
            case NET_NNTP_PROTOCOL_RESPONSECODE_TLS_FAILED_NEGOTIATION:
1228
                return $this->throwError('Can not initiate TLS negotiation', $response, $this->_currentStatusResponse());
1229
            default:
1230
                $text = $this->_currentStatusResponse();
1231
1232
                return $this->throwError("Unexpected response: '$text'", $response, $text);
1233
        }
1234
    }
1235
1236
    /**
1237
     * Connect to a NNTP server.
1238
     *
1239
     * @param  string|null  $host  (optional) The address of the NNTP-server to connect to, defaults to 'localhost'.
1240
     * @param  mixed|null  $encryption  (optional) Use TLS/SSL on the connection?
1241
     *                                  (string) 'tcp'                 => Use no encryption.
1242
     *                                  'ssl', 'sslv3', 'tls' => Use encryption.
1243
     *                                  (null)|(false) Use no encryption.
1244
     * @param  int|null  $port  (optional) The port number to connect to, defaults to 119.
1245
     * @param  int|null  $timeout  (optional) How many seconds to wait before giving up when connecting.
1246
     * @param  int  $socketTimeout  (optional) How many seconds to wait before timing out the (blocked) socket.
1247
     * @return mixed (bool) On success: True when posting allowed, otherwise false.
1248
     *                      (object) On failure: pear_error
1249
     */
1250
    public function connect(?string $host = null, mixed $encryption = null, ?int $port = null, ?int $timeout = 15, int $socketTimeout = 120): mixed
1251
    {
1252
        if ($this->_isConnected()) {
1253
            return $this->throwError('Already connected, disconnect first!', null);
1254
        }
1255
        // v1.0.x API
1256
        if (is_int($encryption)) {
1257
            trigger_error('You are using deprecated API v1.0 in Net_NNTP_Protocol_Client: connect() !', E_USER_NOTICE);
1258
            $port = $encryption;
1259
            $encryption = false;
1260
        }
1261
        if ($host === null) {
1262
            $host = 'localhost';
1263
        }
1264
        // Choose transport based on encryption, and if no port is given, use default for that encryption.
1265
        switch ($encryption) {
1266
            case null:
1267
            case 'tcp':
1268
                $transport = 'tcp';
1269
                $port = $port ?? 119;
1270
                break;
1271
            case 'ssl':
1272
            case 'tls':
1273
                $transport = $encryption;
1274
                $port = $port ?? 563;
1275
                break;
1276
            default:
1277
                $message = '$encryption parameter must be either tcp, tls, ssl.';
1278
                trigger_error($message, E_USER_ERROR);
1279
        }
1280
        // Attempt to connect to usenet.
1281
        // Only create SSL context if using TLS/SSL transport
1282
        $context = preg_match('/tls|ssl/', $transport)
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $transport does not seem to be defined for all execution paths leading up to this point.
Loading history...
1283
            ? stream_context_create(streamSslContextOptions())
1284
            : null;
1285
1286
        $socket = stream_socket_client(
1287
            $transport.'://'.$host.':'.$port,
1288
            $errorNumber,
1289
            $errorString,
1290
            $timeout,
1291
            STREAM_CLIENT_CONNECT,
1292
            $context
1293
        );
1294
        if ($socket === false) {
1295
            $message = "Connection to $transport://$host:$port failed.";
1296
            if (preg_match('/tls|ssl/', $transport)) {
1297
                $message .= ' Try disabling SSL/TLS, and/or try a different port.';
1298
            }
1299
            $message .= ' [ERROR '.$errorNumber.': '.$errorString.']';
1300
1301
            return $this->throwError($message);
1302
        }
1303
        // Store the socket resource as property.
1304
        $this->_socket = $socket;
1305
        $this->_socketTimeout = $socketTimeout ?: $this->_socketTimeout;
1306
        // Set the socket timeout.
1307
        stream_set_timeout($this->_socket, $this->_socketTimeout);
1308
        // Retrieve the server's initial response.
1309
        $response = $this->_getStatusResponse();
1310
        if (self::isError($response)) {
1311
            return $response;
1312
        }
1313
        switch ($response) {
1314
            // 200, Posting allowed
1315
            case NET_NNTP_PROTOCOL_RESPONSECODE_READY_POSTING_ALLOWED:
1316
                return true;
1317
                // 201, Posting NOT allowed
1318
            case NET_NNTP_PROTOCOL_RESPONSECODE_READY_POSTING_PROHIBITED:
1319
1320
                return false;
1321
            default:
1322
                return $this->_handleErrorResponse($response);
1323
        }
1324
    }
1325
1326
    /**
1327
     * Test whether we are connected or not.
1328
     *
1329
     * @param  bool  $feOf  Check for the end of file pointer.
1330
     * @return bool true or false
1331
     */
1332
    public function _isConnected(bool $feOf = true): bool
1333
    {
1334
        return is_resource($this->_socket) && (! $feOf || ! feof($this->_socket));
1335
    }
1336
1337
    /**
1338
     * Check if we are connected to the usenet server.
1339
     */
1340
    public function isConnected(): bool
1341
    {
1342
        return $this->_isConnected();
1343
    }
1344
1345
    /**
1346
     * Get the current server address.
1347
     */
1348
    public function getCurrentServer(): string
1349
    {
1350
        return $this->_currentServer;
1351
    }
1352
1353
    /**
1354
     * Get the current port.
1355
     */
1356
    public function getCurrentPort(): int|string
1357
    {
1358
        return $this->_currentPort;
1359
    }
1360
1361
    /**
1362
     * Check if posting is allowed on the current connection.
1363
     */
1364
    public function isPostingAllowed(): bool
1365
    {
1366
        return $this->_postingAllowed;
1367
    }
1368
1369
    /**
1370
     * Get the currently selected group.
1371
     */
1372
    public function getCurrentGroup(): string
1373
    {
1374
        return $this->_currentGroup;
1375
    }
1376
1377
    /**
1378
     * Check if compression is enabled.
1379
     */
1380
    public function isCompressionEnabled(): bool
1381
    {
1382
        return $this->_compressionEnabled;
1383
    }
1384
1385
    /**
1386
     * Check if compression is supported.
1387
     */
1388
    public function isCompressionSupported(): bool
1389
    {
1390
        return $this->_compressionSupported;
1391
    }
1392
}
1393
1394