Completed
Push — dev ( 96fe6c...04e8b1 )
by Darko
08:41
created

Binaries::updateBlacklistUsage()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
ccs 0
cts 3
cp 0
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Blacklight;
4
5
use App\Models\Group;
6
use App\Models\Settings;
7
use App\Models\Collection;
8
use Illuminate\Support\Str;
9
use Illuminate\Support\Carbon;
10
use App\Models\BinaryBlacklist;
11
use Illuminate\Support\Facades\DB;
12
use Illuminate\Support\Facades\Log;
13
use Illuminate\Database\QueryException;
14
15
/**
16
 * Class Binaries.
17
 */
18
class Binaries
19
{
20
    public const OPTYPE_BLACKLIST = 1;
21
    public const OPTYPE_WHITELIST = 2;
22
23
    public const BLACKLIST_DISABLED = 0;
24
    public const BLACKLIST_ENABLED = 1;
25
26
    public const BLACKLIST_FIELD_SUBJECT = 1;
27
    public const BLACKLIST_FIELD_FROM = 2;
28
    public const BLACKLIST_FIELD_MESSAGEID = 3;
29
30
    /**
31
     * @var array
32
     */
33
    public $blackList = [];
34
35
    /**
36
     * @var array
37
     */
38
    public $whiteList = [];
39
40
    /**
41
     * @var int
42
     */
43
    public $messageBuffer;
44
45
    /**
46
     * @var \Blacklight\ColorCLI
47
     */
48
    protected $colorCli;
49
50
    /**
51
     * @var \Blacklight\CollectionsCleaning
52
     */
53
    protected $_collectionsCleaning;
54
55
    /**
56
     * @var \Blacklight\NNTP
57
     */
58
    protected $_nntp;
59
60
    /**
61
     * Should we use header compression?
62
     *
63
     * @var bool
64
     */
65
    protected $_compressedHeaders;
66
67
    /**
68
     * Should we use part repair?
69
     *
70
     * @var bool
71
     */
72
    protected $_partRepair;
73
74
    /**
75
     * @var \PDO
76
     */
77
    protected $_pdo;
78
79
    /**
80
     * How many days to go back on a new group?
81
     *
82
     * @var bool
83
     */
84
    protected $_newGroupScanByDays;
85
86
    /**
87
     * How many headers to download on new groups?
88
     *
89
     * @var int
90
     */
91
    protected $_newGroupMessagesToScan;
92
93
    /**
94
     * How many days to go back on new groups?
95
     *
96
     * @var int
97
     */
98
    protected $_newGroupDaysToScan;
99
100
    /**
101
     * How many headers to download per run of part repair?
102
     *
103
     * @var int
104
     */
105
    protected $_partRepairLimit;
106
107
    /**
108
     * Echo to cli?
109
     *
110
     * @var bool
111
     */
112
    protected $_echoCLI;
113
114
    /**
115
     * Max tries to download headers.
116
     * @var int
117
     */
118
    protected $_partRepairMaxTries;
119
120
    /**
121
     * An array of BinaryBlacklist IDs that should have their activity date updated.
122
     * @var array(int)
123
     */
124
    protected $_binaryBlacklistIdsToUpdate = [];
125
126
    /**
127
     * @var \DateTime
128
     */
129
    protected $startCleaning;
130
131
    /**
132
     * @var \DateTime
133
     */
134
    protected $startLoop;
135
136
    /**
137
     * @var int How long it took in seconds to download headers
138
     */
139
    protected $timeHeaders;
140
141
    /**
142
     * @var int How long it took in seconds to clean/parse headers
143
     */
144
    protected $timeCleaning;
145
146
    /**
147
     * @var \DateTime
148
     */
149
    protected $startPR;
150
151
    /**
152
     * @var \DateTime
153
     */
154
    protected $startUpdate;
155
156
    /**
157
     * @var int The time it took to insert the headers
158
     */
159
    protected $timeInsert;
160
161
    /**
162
     * @var array the header currently being scanned
163
     */
164
    protected $header;
165
166
    /**
167
     * @var bool Should we add parts to part repair queue?
168
     */
169
    protected $addToPartRepair;
170
171
    /**
172
     * @var array Numbers of Headers received from the USP
173
     */
174
    protected $headersReceived;
175
176
    /**
177
     * @var array The current newsgroup information being updated
178
     */
179
    protected $groupMySQL;
180
181
    /**
182
     * @var int the last article number in the range
183
     */
184
    protected $last;
185
186
    /**
187
     * @var int the first article number in the range
188
     */
189
    protected $first;
190
191
    /**
192
     * @var int How many received headers were not yEnc encoded
193
     */
194
    protected $notYEnc;
195
196
    /**
197
     * @var int How many received headers were blacklist matched
198
     */
199
    protected $headersBlackListed;
200
201
    /**
202
     * @var array Header numbers that were not inserted
203
     */
204
    protected $headersNotInserted;
205
206
    /**
207
     * Constructor.
208
     *
209
     * @param array $options Class instances / echo to CLI?
210
     *
211
     * @throws \Exception
212
     */
213
    public function __construct(array $options = [])
214
    {
215
        $defaults = [
216
            'Echo'                => true,
217
            'CollectionsCleaning' => null,
218
            'ColorCLI'            => null,
219
            'Logger'              => null,
220
            'Groups'              => null,
221
            'NNTP'                => null,
222
            'Settings'            => null,
223
        ];
224
        $options += $defaults;
225
226
        $this->_echoCLI = ($options['Echo'] && config('nntmux.echocli'));
227
228
        $this->_pdo = DB::connection()->getPdo();
229
        $this->colorCli = ($options['ColorCLI'] instanceof ColorCLI ? $options['ColorCLI'] : new ColorCLI());
230
        $this->_nntp = ($options['NNTP'] instanceof NNTP ? $options['NNTP'] : new NNTP(['Echo' => $this->colorCli, 'ColorCLI' => $this->colorCli]));
231
        $this->_collectionsCleaning = ($options['CollectionsCleaning'] instanceof CollectionsCleaning ? $options['CollectionsCleaning'] : new CollectionsCleaning());
232
233
        $this->messageBuffer = Settings::settingValue('..maxmssgs') !== '' ?
0 ignored issues
show
introduced by
The condition App\Models\Settings::set...ue('..maxmssgs') !== '' is always true.
Loading history...
234
            (int) Settings::settingValue('..maxmssgs') : 20000;
235
        $this->_compressedHeaders = (int) Settings::settingValue('..compressedheaders') === 1;
236
        $this->_partRepair = (int) Settings::settingValue('..partrepair') === 1;
237
        $this->_newGroupScanByDays = (int) Settings::settingValue('..newgroupscanmethod') === 1;
238
        $this->_newGroupMessagesToScan = Settings::settingValue('..newgroupmsgstoscan') !== '' ? (int) Settings::settingValue('..newgroupmsgstoscan') : 50000;
0 ignored issues
show
introduced by
The condition App\Models\Settings::set...roupmsgstoscan') !== '' is always true.
Loading history...
239
        $this->_newGroupDaysToScan = Settings::settingValue('..newgroupdaystoscan') !== '' ? (int) Settings::settingValue('..newgroupdaystoscan') : 3;
0 ignored issues
show
introduced by
The condition App\Models\Settings::set...roupdaystoscan') !== '' is always true.
Loading history...
240
        $this->_partRepairLimit = Settings::settingValue('..maxpartrepair') !== '' ? (int) Settings::settingValue('..maxpartrepair') : 15000;
0 ignored issues
show
introduced by
The condition App\Models\Settings::set....maxpartrepair') !== '' is always true.
Loading history...
241
        $this->_partRepairMaxTries = (Settings::settingValue('..partrepairmaxtries') !== '' ? (int) Settings::settingValue('..partrepairmaxtries') : 3);
0 ignored issues
show
introduced by
The condition App\Models\Settings::set...repairmaxtries') !== '' is always true.
Loading history...
242
243
        $this->blackList = $this->whiteList = [];
244
    }
245
246
    /**
247
     * Download new headers for all active groups.
248
     *
249
     * @param int $maxHeaders (Optional) How many headers to download max.
250
     *
251
     * @return void
252
     * @throws \Exception
253
     * @throws \Throwable
254
     */
255
    public function updateAllGroups($maxHeaders = 100000): void
256
    {
257
        $groups = Group::getActive();
258
259
        $groupCount = \count($groups);
260
        if ($groupCount > 0) {
261
            $counter = 1;
262
            $allTime = now();
263
264
            $this->log(
265
                'Updating: '.$groupCount.' group(s) - Using compression? '.($this->_compressedHeaders ? 'Yes' : 'No'),
266
                __FUNCTION__,
267
                'header'
268
            );
269
270
            // Loop through groups.
271
            foreach ($groups as $group) {
272
                $this->log(
273
                    'Starting group '.$counter.' of '.$groupCount,
274
                    __FUNCTION__,
275
                    'header'
276
                );
277
                $this->updateGroup($group, $maxHeaders);
278
                $counter++;
279
            }
280
281
            $this->log(
282
                'Updating completed in '.Str::plural(' second', now()->diffInSeconds($allTime)),
283
                __FUNCTION__,
284
                'primary'
285
            );
286
        } else {
287
            $this->log(
288
                'No groups specified. Ensure groups are added to NNTmux\'s database for updating.',
289
                __FUNCTION__,
290
                'warning'
291
            );
292
        }
293
    }
294
295
    /**
296
     * When the indexer is started, log the date/time.
297
     */
298
    public function logIndexerStart(): void
299
    {
300
        Settings::query()->where('setting', '=', 'last_run_time')->update(['value' => now()]);
301
    }
302
303
    /**
304
     * Download new headers for a single group.
305
     *
306
     * @param array $groupMySQL Array of MySQL results for a single group.
307
     * @param int   $maxHeaders (Optional) How many headers to download max.
308
     *
309
     * @return void
310
     * @throws \Exception
311
     * @throws \Throwable
312
     */
313
    public function updateGroup($groupMySQL, $maxHeaders = 0): void
314
    {
315
        $startGroup = now();
316
317
        $this->logIndexerStart();
318
319
        // Select the group on the NNTP server, gets the latest info on it.
320
        $groupNNTP = $this->_nntp->selectGroup($groupMySQL['name']);
321
        if ($this->_nntp->isError($groupNNTP)) {
322
            $groupNNTP = $this->_nntp->dataError($this->_nntp, $groupMySQL['name']);
323
324
            if (isset($groupNNTP['code']) && (int) $groupNNTP['code'] === 411) {
325
                Group::disableIfNotExist($groupMySQL['id']);
326
            }
327
            if ($this->_nntp->isError($groupNNTP)) {
328
                return;
329
            }
330
        }
331
332
        if ($this->_echoCLI) {
333
            $this->colorCli->primary('Processing '.$groupMySQL['name']);
334
        }
335
336
        // Attempt to repair any missing parts before grabbing new ones.
337
        if ((int) $groupMySQL['last_record'] !== 0) {
338
            if ($this->_partRepair) {
339
                if ($this->_echoCLI) {
340
                    $this->colorCli->primary('Part repair enabled. Checking for missing parts.');
341
                }
342
                $this->partRepair($groupMySQL);
343
            } elseif ($this->_echoCLI) {
344
                $this->colorCli->primary('Part repair disabled by user.');
345
            }
346
        }
347
348
        // Generate postdate for first record, for those that upgraded.
349
        if ($groupMySQL['first_record_postdate'] === null && (int) $groupMySQL['first_record'] !== 0) {
350
            $groupMySQL['first_record_postdate'] = $this->postdate($groupMySQL['first_record'], $groupNNTP);
0 ignored issues
show
Bug introduced by
It seems like $groupNNTP can also be of type integer and null; however, parameter $groupData of Blacklight\Binaries::postdate() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

350
            $groupMySQL['first_record_postdate'] = $this->postdate($groupMySQL['first_record'], /** @scrutinizer ignore-type */ $groupNNTP);
Loading history...
351
            Group::query()->where('id', $groupMySQL['id'])->update(['first_record_postdate' => Carbon::createFromTimestamp($groupMySQL['first_record_postdate'])]);
352
        }
353
354
        // Get first article we want aka the oldest.
355
        if ((int) $groupMySQL['last_record'] === 0) {
356
            if ($this->_newGroupScanByDays) {
357
                // For new newsgroups - determine here how far we want to go back using date.
358
                $first = $this->daytopost($this->_newGroupDaysToScan, $groupNNTP);
0 ignored issues
show
Bug introduced by
It seems like $groupNNTP can also be of type integer; however, parameter $data of Blacklight\Binaries::daytopost() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

358
                $first = $this->daytopost($this->_newGroupDaysToScan, /** @scrutinizer ignore-type */ $groupNNTP);
Loading history...
359
            } elseif ($groupNNTP['first'] >= ($groupNNTP['last'] - ($this->_newGroupMessagesToScan + $this->messageBuffer))) {
360
                // If what we want is lower than the groups first article, set the wanted first to the first.
361
                $first = $groupNNTP['first'];
362
            } else {
363
                // Or else, use the newest article minus how much we should get for new groups.
364
                $first = (string) ($groupNNTP['last'] - ($this->_newGroupMessagesToScan + $this->messageBuffer));
365
            }
366
367
            // We will use this to subtract so we leave articles for the next time (in case the server doesn't have them yet)
368
            $leaveOver = $this->messageBuffer;
369
370
        // If this is not a new group, go from our newest to the servers newest.
371
        } else {
372
            // Set our oldest wanted to our newest local article.
373
            $first = $groupMySQL['last_record'];
374
375
            // This is how many articles we will grab. (the servers newest minus our newest).
376
            $totalCount = (string) ($groupNNTP['last'] - $first);
377
378
            // Check if the server has more articles than our loop limit x 2.
379
            if ($totalCount > ($this->messageBuffer * 2)) {
380
                // Get the remainder of $totalCount / $this->message buffer
381
                $leaveOver = round($totalCount % $this->messageBuffer, 0, PHP_ROUND_HALF_DOWN) + $this->messageBuffer;
382
            } else {
383
                // Else get half of the available.
384
                $leaveOver = round($totalCount / 2, 0, PHP_ROUND_HALF_DOWN);
385
            }
386
        }
387
388
        // The last article we want, aka the newest.
389
        $last = $groupLast = (string) ($groupNNTP['last'] - $leaveOver);
390
391
        // If the newest we want is older than the oldest we want somehow.. set them equal.
392
        if ($last < $first) {
393
            $last = $groupLast = $first;
394
        }
395
396
        // This is how many articles we are going to get.
397
        $total = (string) ($groupLast - $first);
398
        // This is how many articles are available (without $leaveOver).
399
        $realTotal = (string) ($groupNNTP['last'] - $first);
400
401
        // Check if we should limit the amount of fetched new headers.
402
        if ($maxHeaders > 0) {
403
            if ($maxHeaders < ($groupLast - $first)) {
404
                $groupLast = $last = (string) ($first + $maxHeaders);
405
            }
406
            $total = (string) ($groupLast - $first);
407
        }
408
409
        // If total is bigger than 0 it means we have new parts in the newsgroup.
410
        if ($total > 0) {
411
            if ($this->_echoCLI) {
412
                $this->colorCli->primary(
413
                        (
414
                        (int) $groupMySQL['last_record'] === 0
415
                            ? 'New group '.$groupNNTP['group'].' starting with '.
416
                            (
417
                            $this->_newGroupScanByDays
418
                                ? $this->_newGroupDaysToScan.' days'
419
                                : number_format($this->_newGroupMessagesToScan).' messages'
420
                            ).' worth.'
421
                            : 'Group '.$groupNNTP['group'].' has '.number_format($realTotal).' new articles.'
0 ignored issues
show
Bug introduced by
$realTotal of type string is incompatible with the type double expected by parameter $number of number_format(). ( Ignorable by Annotation )

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

421
                            : 'Group '.$groupNNTP['group'].' has '.number_format(/** @scrutinizer ignore-type */ $realTotal).' new articles.'
Loading history...
422
                        ).
423
                        ' Leaving '.number_format($leaveOver).
424
                        " for next pass.\nServer oldest: ".number_format($groupNNTP['first']).
425
                        ' Server newest: '.number_format($groupNNTP['last']).
426
                        ' Local newest: '.number_format($groupMySQL['last_record'])
427
                    );
428
            }
429
430
            $done = false;
431
            // Get all the parts (in portions of $this->messageBuffer to not use too much memory).
432
            while ($done === false) {
433
434
                // Increment last until we reach $groupLast (group newest article).
435
                if ($total > $this->messageBuffer) {
436
                    if ((string) ($first + $this->messageBuffer) > $groupLast) {
437
                        $last = $groupLast;
438
                    } else {
439
                        $last = (string) ($first + $this->messageBuffer);
440
                    }
441
                }
442
                // Increment first so we don't get an article we already had.
443
                $first++;
444
445
                if ($this->_echoCLI) {
446
                    $this->colorCli->header(
447
                            PHP_EOL.'Getting '.number_format($last - $first + 1).' articles ('.number_format($first).
448
                            ' to '.number_format($last).') from '.$groupMySQL['name'].' - ('.
449
                            number_format($groupLast - $last).' articles in queue).'
450
                        );
451
                }
452
453
                // Get article headers from newsgroup.
454
                $scanSummary = $this->scan($groupMySQL, $first, $last);
455
456
                // Check if we fetched headers.
457
                if (! empty($scanSummary)) {
458
459
                    // If new group, update first record & postdate
460
                    if ($groupMySQL['first_record_postdate'] === null && (int) $groupMySQL['first_record'] === 0) {
461
                        $groupMySQL['first_record'] = $scanSummary['firstArticleNumber'];
462
463
                        if (isset($scanSummary['firstArticleDate'])) {
464
                            $groupMySQL['first_record_postdate'] = strtotime($scanSummary['firstArticleDate']);
465
                        } else {
466
                            $groupMySQL['first_record_postdate'] = $this->postdate($groupMySQL['first_record'], $groupNNTP);
467
                        }
468
469
                        Group::query()
470
                            ->where('id', $groupMySQL['id'])
471
                            ->update(
472
                                [
473
                                    'first_record' => $scanSummary['firstArticleNumber'],
474
                                    'first_record_postdate' => Carbon::createFromTimestamp(
475
                                        $groupMySQL['first_record_postdate']
476
                                    ),
477
                                ]
478
                            );
479
                    }
480
481
                    $scanSummary['lastArticleDate'] = (isset($scanSummary['lastArticleDate']) ? strtotime($scanSummary['lastArticleDate']) : false);
482
                    if (! is_numeric($scanSummary['lastArticleDate'])) {
483
                        $scanSummary['lastArticleDate'] = $this->postdate($scanSummary['lastArticleNumber'], $groupNNTP);
484
                    }
485
486
                    Group::query()
487
                        ->where('id', $groupMySQL['id'])
488
                        ->update(
489
                            [
490
                                'last_record' => $scanSummary['lastArticleNumber'],
491
                                'last_record_postdate' => Carbon::createFromTimestamp($scanSummary['lastArticleDate']),
492
                                'last_updated' => now(),
493
                            ]
494
                        );
495
                } else {
496
                    // If we didn't fetch headers, update the record still.
497
                    Group::query()
498
                        ->where('id', $groupMySQL['id'])
499
                        ->update(
500
                            [
501
                                'last_record' => $last,
502
                                'last_updated' => now(),
503
                            ]
504
                        );
505
                }
506
507
                if ((int) $last === (int) $groupLast) {
508
                    $done = true;
509
                } else {
510
                    $first = $last;
511
                }
512
            }
513
514
            if ($this->_echoCLI) {
515
                $this->colorCli->primary(
516
                        PHP_EOL.'Group '.$groupMySQL['name'].' processed in '.
517
                        Str::plural(' second', now()->diffInSeconds($startGroup))
518
                    );
519
            }
520
        } elseif ($this->_echoCLI) {
521
            $this->colorCli->primary(
522
                    'No new articles for '.$groupMySQL['name'].' (first '.number_format($first).
523
                    ', last '.number_format($last).', grouplast '.number_format($groupMySQL['last_record']).
524
                    ', total '.number_format($total).")\n".'Server oldest: '.number_format($groupNNTP['first']).
525
                    ' Server newest: '.number_format($groupNNTP['last']).' Local newest: '.number_format($groupMySQL['last_record'])
526
                );
527
        }
528
    }
529
530
    /**
531
     * Loop over range of wanted headers, insert headers into DB.
532
     *
533
     * @param array      $groupMySQL   The group info from mysql.
534
     * @param int        $first        The oldest wanted header.
535
     * @param int        $last         The newest wanted header.
536
     * @param string     $type         Is this partrepair or update or backfill?
537
     * @param null|array $missingParts If we are running in partrepair, the list of missing article numbers.
538
     *
539
     * @return array Empty on failure.
540
     * @throws \Exception
541
     * @throws \Throwable
542
     */
543
    public function scan($groupMySQL, $first, $last, $type = 'update', $missingParts = null): array
544
    {
545
        // Start time of scan method and of fetching headers.
546
        $this->startLoop = now();
547
        $this->groupMySQL = $groupMySQL;
548
        $this->last = $last;
549
        $this->first = $first;
550
551
        $this->notYEnc = $this->headersBlackListed = 0;
552
553
        $returnArray = $stdHeaders = [];
554
555
        $partRepair = ($type === 'partrepair');
556
        $this->addToPartRepair = ($type === 'update' && $this->_partRepair);
557
558
        // Download the headers.
559
        if ($partRepair === true) {
560
            // This is slower but possibly is better with missing headers.
561
            $headers = $this->_nntp->getOverview($this->first.'-'.$this->last, true, false);
562
        } else {
563
            $headers = $this->_nntp->getXOVER($this->first.'-'.$this->last);
564
        }
565
566
        // If there was an error, try to reconnect.
567
        if ($this->_nntp->isError($headers)) {
568
569
            // Increment if part repair and return false.
570
            if ($partRepair === true) {
571
                DB::update(
572
                    sprintf(
573
                        'UPDATE missed_parts SET attempts = attempts + 1 WHERE groups_id = %d AND numberid %s',
574
                        $this->groupMySQL['id'],
575
                        ((int) $this->first === (int) $this->last ? '= '.$this->first : 'IN ('.implode(',', range($this->first, $this->last)).')')
576
                    )
577
                );
578
579
                return $returnArray;
580
            }
581
582
            // This is usually a compression error, so try disabling compression.
583
            $this->_nntp->doQuit();
584
            if ($this->_nntp->doConnect(false) !== true) {
585
                return $returnArray;
586
            }
587
588
            // Re-select group, download headers again without compression and re-enable compression.
589
            $this->_nntp->selectGroup($this->groupMySQL['name']);
590
            $headers = $this->_nntp->getXOVER($this->first.'-'.$this->last);
591
            $this->_nntp->enableCompression();
592
593
            // Check if the non-compression headers have an error.
594
            if ($this->_nntp->isError($headers)) {
595
                $message = ((int) $headers->code === 0 ? 'Unknown error' : $headers->message);
0 ignored issues
show
Bug introduced by
The property message does not exist on Blacklight\NNTP. Did you mean error_message_prefix?
Loading history...
Bug introduced by
The property code does not seem to exist on Blacklight\NNTP.
Loading history...
596
                $this->log(
597
                    "Code {$headers->code}: $message\nSkipping group: {$this->groupMySQL['name']}",
598
                    __FUNCTION__,
599
                    'error'
600
                );
601
602
                return $returnArray;
603
            }
604
        }
605
606
        // Start of processing headers.
607
        $this->startCleaning = now();
608
609
        // End of the getting data from usenet.
610
        $this->timeHeaders = $this->startCleaning->diffInSeconds($this->startLoop);
611
612
        // Check if we got headers.
613
        $msgCount = \count($headers);
614
615
        if ($msgCount < 1) {
616
            return $returnArray;
617
        }
618
619
        $this->getHighLowArticleInfo($returnArray, $headers, $msgCount);
620
621
        $headersRepaired = $rangeNotReceived = $this->headersReceived = $this->headersNotInserted = [];
622
623
        foreach ($headers as $header) {
624
625
            // Check if we got the article or not.
626
            if (isset($header['Number'])) {
627
                $this->headersReceived[] = $header['Number'];
628
            } else {
629
                if ($this->addToPartRepair) {
630
                    $rangeNotReceived[] = $header['Number'];
631
                }
632
                continue;
633
            }
634
635
            // If set we are running in partRepair mode.
636
            if ($partRepair === true && $missingParts !== null) {
637
                if (! \in_array($header['Number'], $missingParts, false)) {
638
                    // If article isn't one that is missing skip it.
639
                    continue;
640
                }
641
                // We got the part this time. Remove article from part repair.
642
                $headersRepaired[] = $header['Number'];
643
            }
644
645
            /*
646
             * Find part / total parts. Ignore if no part count found.
647
             *
648
             * \s* Trims the leading space.
649
             * (?!"Usenet Index Post) ignores these types of articles, they are useless.
650
             * (.+) Fetches the subject.
651
             * \s+ Trims trailing space after the subject.
652
             * \((\d+)\/(\d+)\) Gets the part count.
653
             * No ending ($) as there are cases of subjects with extra data after the part count.
654
             */
655
            if (preg_match('/^\s*(?!"Usenet Index Post)(.+)\s+\((\d+)\/(\d+)\)/', $header['Subject'], $header['matches'])) {
656
                // Add yEnc to subjects that do not have them, but have the part number at the end of the header.
657
                if (stripos($header['Subject'], 'yEnc') === false) {
658
                    $header['matches'][1] .= ' yEnc';
659
                }
660
            } else {
661
                $this->notYEnc++;
662
                continue;
663
            }
664
665
            // Filter subject based on black/white list.
666
            if ($this->isBlackListed($header, $this->groupMySQL['name'])) {
667
                $this->headersBlackListed++;
668
                continue;
669
            }
670
671
            if (! isset($header['Bytes'])) {
672
                $header['Bytes'] = (isset($this->header[':bytes']) ? $header[':bytes'] : 0);
673
            }
674
675
            $stdHeaders[] = $header;
676
        }
677
678
        unset($headers); // Reclaim memory now that headers are split.
679
680
        if (! empty($this->_binaryBlacklistIdsToUpdate)) {
681
            $this->updateBlacklistUsage();
682
        }
683
684
        if ($this->_echoCLI && $partRepair === false) {
685
            $this->outputHeaderInitial();
686
        }
687
688
        if (! empty($stdHeaders)) {
689
            $this->storeHeaders($stdHeaders);
690
        }
691
        unset($stdHeaders);
692
693
        // Start of part repair.
694
        $this->startPR = now();
695
696
        // End of inserting.
697
        $this->timeInsert = $this->startPR->diffInSeconds($this->startUpdate);
698
699
        if ($partRepair && \count($headersRepaired) > 0) {
700
            $this->removeRepairedParts($headersRepaired, $this->groupMySQL['id']);
701
        }
702
        unset($headersRepaired);
703
704
        if ($this->addToPartRepair) {
705
            $notInsertedCount = \count($this->headersNotInserted);
706
            if ($notInsertedCount > 0) {
707
                $this->addMissingParts($this->headersNotInserted, $this->groupMySQL['id']);
708
709
                $this->log(
710
                    $notInsertedCount.' articles failed to insert!',
711
                    __FUNCTION__,
712
                    'warning'
713
                );
714
715
                if (config('app.debug' === true)) {
716
                    Log::warning($notInsertedCount.' articles failed to insert!');
717
                }
718
            }
719
            unset($this->headersNotInserted);
720
721
            // Check if we have any missing headers.
722
            if (($this->last - $this->first - $this->notYEnc - $this->headersBlackListed + 1) > \count($this->headersReceived)) {
723
                $rangeNotReceived = array_merge($rangeNotReceived, array_diff(range($this->first, $this->last), $this->headersReceived));
724
            }
725
            $notReceivedCount = \count($rangeNotReceived);
726
            if ($notReceivedCount > 0) {
727
                $this->addMissingParts($rangeNotReceived, $this->groupMySQL['id']);
728
729
                if ($this->_echoCLI) {
730
                    $this->colorCli->alternate(
731
                            'Server did not return '.$notReceivedCount.
732
                            ' articles from '.$this->groupMySQL['name'].'.'
733
                        );
734
                }
735
            }
736
            unset($rangeNotReceived);
737
        }
738
739
        $this->outputHeaderDuration();
740
741
        return $returnArray;
742
    }
743
744
    /**
745
     * Parse headers into collections/binaries and store header data as parts.
746
     *
747
     *
748
     * @param array $headers
749
     *
750
     * @throws \Exception
751
     * @throws \Throwable
752
     */
753
    protected function storeHeaders(array $headers = []): void
754
    {
755
        $binariesUpdate = $collectionIDs = $articles = [];
756
757
        DB::beginTransaction();
758
759
        $partsQuery = $partsCheck = 'INSERT IGNORE INTO parts (binaries_id, number, messageid, partnumber, size) VALUES ';
760
761
        // Loop articles, figure out files/parts.
762
        foreach ($headers as $this->header) {
763
            // Set up the info for inserting into parts/binaries/collections tables.
764
            if (! isset($articles[$this->header['matches'][1]])) {
765
766
                // check whether file count should be ignored (XXX packs for now only).
767
                $whitelistMatch = false;
768
                if ($this->_ignoreFileCount($this->groupMySQL['name'], $this->header['matches'][1])) {
769
                    $whitelistMatch = true;
770
                    $fileCount[1] = $fileCount[3] = 0;
771
                }
772
773
                // Attempt to find the file count. If it is not found, set it to 0.
774
                if (! $whitelistMatch && ! preg_match('/[[(\s](\d{1,5})(\/|[\s_]of[\s_]|-)(\d{1,5})[])\s$:]/i', $this->header['matches'][1], $fileCount)) {
775
                    $fileCount[1] = $fileCount[3] = 0;
776
                }
777
778
                $collMatch = $this->_collectionsCleaning->collectionsCleaner(
779
                    $this->header['matches'][1]
780
                );
781
782
                // Used to group articles together when forming the release.
783
                $this->header['CollectionKey'] = $collMatch['name'].$fileCount[3];
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $fileCount does not seem to be defined for all execution paths leading up to this point.
Loading history...
784
785
                // If this header's collection key isn't in memory, attempt to insert the collection
786
                if (! isset($collectionIDs[$this->header['CollectionKey']])) {
787
788
                    /* Date from header should be a string this format:
789
                     * 31 Mar 2014 15:36:04 GMT or 6 Oct 1998 04:38:40 -0500
790
                     * Still make sure it's not unix time, convert it to unix time if it is.
791
                     */
792
                    $this->header['Date'] = (is_numeric($this->header['Date']) ? $this->header['Date'] : strtotime($this->header['Date']));
793
794
                    // Get the current unixtime from PHP.
795
                    $now = now()->timestamp;
796
797
                    $xrefsData = Collection::whereCollectionhash(sha1($this->header['CollectionKey']))->value('xref');
798
799
                    $tempHeaderXrefs = [];
800
                    foreach (explode(' ', $this->header['Xref']) as $headerXref) {
801
                        if (preg_match('/(^[a-zA-Z]{2,3}\.(bin(aries|arios|aer))\.[a-zA-Z0-9]?.+)(\:\d+)/', $headerXref, $match) || preg_match('/(^[a-zA-Z]{2,3}\.(bin(aries|arios|aer))\.[a-zA-Z0-9]?.+)/', $headerXref, $match)) {
802
                            $tempHeaderXrefs[] = $match[0];
803
                        }
804
                    }
805
806
                    $tempXrefsData = [];
807
808
                    if ($xrefsData !== null) {
809
                        foreach (explode(' ', $xrefsData) as $xrefData) {
810
                            if (preg_match('/(^[a-zA-Z]{2,3}\.(bin(aries|arios|aer))\.[a-zA-Z0-9]?.+)(\:\d+)/', $xrefData, $match1) || preg_match('/(^[a-zA-Z]{2,3}\.(bin(aries|arios|aer))\.[a-zA-Z0-9]?.+)/', $xrefData, $match1)) {
811
                                $tempXrefsData[] = $match1[0];
812
                            }
813
                        }
814
                    }
815
816
                    $finalXrefArray = [];
817
                    foreach ($tempHeaderXrefs as $tempHeaderXref) {
818
                        if (! in_array($tempHeaderXref, $tempXrefsData, false)) {
819
                            $finalXrefArray[] = $tempHeaderXref;
820
                        }
821
                    }
822
823
                    $finaXref = implode(' ', $finalXrefArray);
824
825
                    $xref = sprintf('xref = CONCAT(xref, "\\n"%s ),', escapeString($finaXref));
826
827
                    $date = $this->header['Date'] > $now ? $now : $this->header['Date'];
828
                    $unixtime = is_numeric($this->header['Date']) ? $date : $now;
829
830
                    $random = random_bytes(16);
831
832
                    $collectionID = false;
833
834
                    try {
835
                        DB::insert(sprintf("
836
							INSERT INTO collections (subject, fromname, date, xref, groups_id,
837
								totalfiles, collectionhash, collection_regexes_id, dateadded)
838
							VALUES (%s, %s, FROM_UNIXTIME(%s), %s, %d, %d, '%s', %d, NOW())
839
							ON DUPLICATE KEY UPDATE %s dateadded = NOW(), noise = '%s'", escapeString(substr(utf8_encode($this->header['matches'][1]), 0, 255)), escapeString(utf8_encode($this->header['From'])), $unixtime, escapeString(implode(' ', $tempHeaderXrefs)), $this->groupMySQL['id'], $fileCount[3], sha1($this->header['CollectionKey']), $collMatch['id'], $xref, sodium_bin2hex($random)));
840
                        $collectionID = $this->_pdo->lastInsertId();
841
                        DB::commit();
842
                    } catch (\Throwable $e) {
843
                        if (config('app.debug' === true)) {
844
                            Log::error($e->getMessage());
845
                        }
846
                        DB::rollBack();
847
                    }
848
849
                    if ($collectionID === false) {
850
                        if ($this->addToPartRepair) {
851
                            $this->headersNotInserted[] = $this->header['Number'];
852
                        }
853
                        DB::rollBack();
854
                        DB::beginTransaction();
855
                        continue;
856
                    }
857
                    $collectionIDs[$this->header['CollectionKey']] = $collectionID;
858
                } else {
859
                    $collectionID = $collectionIDs[$this->header['CollectionKey']];
860
                }
861
862
                // Binary Hash should be unique to the group
863
                $hash = md5($this->header['matches'][1].$this->header['From'].$this->groupMySQL['id']);
864
865
                $binaryID = false;
866
867
                try {
868
                    DB::insert(sprintf("
869
						INSERT INTO binaries (binaryhash, name, collections_id, totalparts, currentparts, filenumber, partsize)
870
						VALUES (UNHEX('%s'), %s, %d, %d, 1, %d, %d)
871
						ON DUPLICATE KEY UPDATE currentparts = currentparts + 1, partsize = partsize + %d", $hash, escapeString(utf8_encode($this->header['matches'][1])), $collectionID, $this->header['matches'][3], $fileCount[1], $this->header['Bytes'], $this->header['Bytes']));
872
                    $binaryID = $this->_pdo->lastInsertId();
873
                    DB::commit();
874
                } catch (\Throwable $e) {
875
                    if (config('app.debug' === true)) {
876
                        Log::error($e->getMessage());
877
                    }
878
                    DB::rollBack();
879
                }
880
881
                if ($binaryID === false) {
882
                    if ($this->addToPartRepair) {
883
                        $this->headersNotInserted[] = $this->header['Number'];
884
                    }
885
                    DB::rollBack();
886
                    DB::beginTransaction();
887
                    continue;
888
                }
889
890
                $binariesUpdate[$binaryID]['Size'] = 0;
891
                $binariesUpdate[$binaryID]['Parts'] = 0;
892
893
                $articles[$this->header['matches'][1]]['CollectionID'] = $collectionID;
894
                $articles[$this->header['matches'][1]]['BinaryID'] = $binaryID;
895
            } else {
896
                $binaryID = $articles[$this->header['matches'][1]]['BinaryID'];
897
                $binariesUpdate[$binaryID]['Size'] += $this->header['Bytes'];
898
                $binariesUpdate[$binaryID]['Parts']++;
899
            }
900
901
            // Strip the < and >, saves space in DB.
902
            $this->header['Message-ID'][0] = "'";
903
904
            $partsQuery .=
905
                '('.$binaryID.','.$this->header['Number'].','.rtrim($this->header['Message-ID'], '>')."',".
906
                $this->header['matches'][2].','.$this->header['Bytes'].'),';
907
        }
908
909
        unset($headers); // Reclaim memory.
910
911
        // Start of inserting into SQL.
912
        $this->startUpdate = now();
913
914
        // End of processing headers.
915
        $this->timeCleaning = $this->startUpdate->diffInSeconds($this->startCleaning);
916
        $binariesQuery = $binariesCheck = 'INSERT INTO binaries (id, partsize, currentparts) VALUES ';
917
        foreach ($binariesUpdate as $binaryID => $binary) {
918
            $binariesQuery .= '('.$binaryID.','.$binary['Size'].','.$binary['Parts'].'),';
919
        }
920
        $binariesEnd = ' ON DUPLICATE KEY UPDATE partsize = VALUES(partsize) + partsize, currentparts = VALUES(currentparts) + currentparts';
921
        $binariesQuery = rtrim($binariesQuery, ',').$binariesEnd;
922
923
        // Check if we got any binaries. If we did, try to insert them.
924
        if (\strlen($binariesCheck.$binariesEnd) === \strlen($binariesQuery) ? true : $this->runQuery($binariesQuery)) {
925
            if (\strlen($partsQuery) === \strlen($partsCheck) ? true : $this->runQuery(rtrim($partsQuery, ','))) {
926
                DB::commit();
927
            } else {
928
                if ($this->addToPartRepair) {
929
                    $this->headersNotInserted += $this->headersReceived;
930
                }
931
                DB::rollBack();
932
            }
933
        } else {
934
            if ($this->addToPartRepair) {
935
                $this->headersNotInserted += $this->headersReceived;
936
            }
937
            DB::rollBack();
938
        }
939
    }
940
941
    /**
942
     * Gets the First and Last Article Number and Date for the received headers.
943
     *
944
     * @param array $returnArray
945
     * @param array $headers
946
     * @param int   $msgCount
947
     */
948
    protected function getHighLowArticleInfo(array &$returnArray, array $headers, int $msgCount): void
949
    {
950
        // Get highest and lowest article numbers/dates.
951
        $iterator1 = 0;
952
        $iterator2 = $msgCount - 1;
953
        while (true) {
954
            if (! isset($returnArray['firstArticleNumber']) && isset($headers[$iterator1]['Number'])) {
955
                $returnArray['firstArticleNumber'] = $headers[$iterator1]['Number'];
956
                $returnArray['firstArticleDate'] = $headers[$iterator1]['Date'];
957
            }
958
959
            if (! isset($returnArray['lastArticleNumber']) && isset($headers[$iterator2]['Number'])) {
960
                $returnArray['lastArticleNumber'] = $headers[$iterator2]['Number'];
961
                $returnArray['lastArticleDate'] = $headers[$iterator2]['Date'];
962
            }
963
964
            // Break if we found non empty articles.
965
            if (isset($returnArray['firstArticleNumber, lastArticleNumber'])) {
966
                break;
967
            }
968
969
            // Break out if we couldn't find anything.
970
            if ($iterator1++ >= $msgCount - 1 || $iterator2-- <= 0) {
971
                break;
972
            }
973
        }
974
    }
975
976
    /**
977
     * Updates Blacklist Regex Timers in DB to reflect last usage.
978
     */
979
    protected function updateBlacklistUsage(): void
980
    {
981
        BinaryBlacklist::query()->whereIn('id', $this->_binaryBlacklistIdsToUpdate)->update(['last_activity' => now()]);
982
        $this->_binaryBlacklistIdsToUpdate = [];
983
    }
984
985
    /**
986
     * Outputs the initial header scan results after yEnc check and blacklist routines.
987
     */
988
    protected function outputHeaderInitial(): void
989
    {
990
        $this->colorCli->primary(
991
                'Received '.\count($this->headersReceived).
992
                ' articles of '.number_format($this->last - $this->first + 1).' requested, '.
993
                $this->headersBlackListed.' blacklisted, '.$this->notYEnc.' not yEnc.'
994
            );
995
    }
996
997
    /**
998
     * Outputs speed metrics of the scan function to CLI.
999
     */
1000
    protected function outputHeaderDuration(): void
1001
    {
1002
        $currentMicroTime = now();
1003
        if ($this->_echoCLI) {
1004
            $this->colorCli->alternateOver($this->timeHeaders.'s').
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->colorCli->alterna...his->timeHeaders . 's') targeting Blacklight\ColorCLI::alternateOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
Bug introduced by
Are you sure $this->colorCli->alterna...his->timeHeaders . 's') of type void 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

1004
            /** @scrutinizer ignore-type */ $this->colorCli->alternateOver($this->timeHeaders.'s').
Loading history...
1005
                $this->colorCli->primaryOver(' to download articles, ').
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->colorCli->primary...o download articles, ') targeting Blacklight\ColorCLI::primaryOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1006
                $this->colorCli->alternateOver($this->timeCleaning.'s').
0 ignored issues
show
Bug introduced by
Are you sure $this->colorCli->alterna...is->timeCleaning . 's') of type void 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

1006
                /** @scrutinizer ignore-type */ $this->colorCli->alternateOver($this->timeCleaning.'s').
Loading history...
Bug introduced by
Are you sure the usage of $this->colorCli->alterna...is->timeCleaning . 's') targeting Blacklight\ColorCLI::alternateOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1007
                $this->colorCli->primaryOver(' to process collections, ').
0 ignored issues
show
Bug introduced by
Are you sure $this->colorCli->primary...process collections, ') of type void 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

1007
                /** @scrutinizer ignore-type */ $this->colorCli->primaryOver(' to process collections, ').
Loading history...
Bug introduced by
Are you sure the usage of $this->colorCli->primary...process collections, ') targeting Blacklight\ColorCLI::primaryOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1008
                $this->colorCli->alternateOver($this->timeInsert.'s').
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->colorCli->alterna...this->timeInsert . 's') targeting Blacklight\ColorCLI::alternateOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
Bug introduced by
Are you sure $this->colorCli->alterna...this->timeInsert . 's') of type void 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

1008
                /** @scrutinizer ignore-type */ $this->colorCli->alternateOver($this->timeInsert.'s').
Loading history...
1009
                $this->colorCli->primaryOver(' to insert binaries/parts, ').
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->colorCli->primary...sert binaries/parts, ') targeting Blacklight\ColorCLI::primaryOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
Bug introduced by
Are you sure $this->colorCli->primary...sert binaries/parts, ') of type void 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

1009
                /** @scrutinizer ignore-type */ $this->colorCli->primaryOver(' to insert binaries/parts, ').
Loading history...
1010
                $this->colorCli->alternateOver($currentMicroTime->diffInSeconds($this->startPR).'s').
0 ignored issues
show
Bug introduced by
Are you sure $this->colorCli->alterna...($this->startPR) . 's') of type void 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

1010
                /** @scrutinizer ignore-type */ $this->colorCli->alternateOver($currentMicroTime->diffInSeconds($this->startPR).'s').
Loading history...
Bug introduced by
Are you sure the usage of $this->colorCli->alterna...($this->startPR) . 's') targeting Blacklight\ColorCLI::alternateOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1011
                $this->colorCli->primaryOver(' for part repair, ').
0 ignored issues
show
Bug introduced by
Are you sure $this->colorCli->primary...r(' for part repair, ') of type void 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

1011
                /** @scrutinizer ignore-type */ $this->colorCli->primaryOver(' for part repair, ').
Loading history...
Bug introduced by
Are you sure the usage of $this->colorCli->primary...r(' for part repair, ') targeting Blacklight\ColorCLI::primaryOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1012
                $this->colorCli->alternateOver($currentMicroTime->diffInSeconds($this->startLoop).'s').
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->colorCli->alterna...this->startLoop) . 's') targeting Blacklight\ColorCLI::alternateOver() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
Bug introduced by
Are you sure $this->colorCli->alterna...this->startLoop) . 's') of type void 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

1012
                /** @scrutinizer ignore-type */ $this->colorCli->alternateOver($currentMicroTime->diffInSeconds($this->startLoop).'s').
Loading history...
1013
                $this->colorCli->primary(' total.');
0 ignored issues
show
Bug introduced by
Are you sure $this->colorCli->primary(' total.') of type void 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

1013
                /** @scrutinizer ignore-type */ $this->colorCli->primary(' total.');
Loading history...
Bug introduced by
Are you sure the usage of $this->colorCli->primary(' total.') targeting Blacklight\ColorCLI::primary() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1014
        }
1015
    }
1016
1017
    /**
1018
     * If we failed to insert Collections/Binaries/Parts, rollback the transaction and add the parts to part repair.
1019
     *
1020
     * @param array $headers Array of headers containing sub-arrays with parts.
1021
     *
1022
     * @return array Array of article numbers to add to part repair.
1023
     * @throws \Exception
1024
     */
1025
    protected function _rollbackAddToPartRepair(array $headers): array
1026
    {
1027
        $headersNotInserted = [];
1028
        foreach ($headers as $header) {
1029
            foreach ($header as $file) {
1030
                $headersNotInserted[] = $file['Parts']['number'];
1031
            }
1032
        }
1033
        DB::rollBack();
1034
1035
        return $headersNotInserted;
1036
    }
1037
1038
    /**
1039
     * Attempt to get missing article headers.
1040
     *
1041
     * @param array        $groupArr The info for this group from mysql.
1042
     *
1043
     * @return void
1044
     * @throws \Exception
1045
     * @throws \Throwable
1046
     */
1047
    public function partRepair($groupArr): void
1048
    {
1049
        // Get all parts in partrepair table.
1050
        $missingParts = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $missingParts is dead and can be removed.
Loading history...
1051
        try {
1052
            $missingParts = DB::select(sprintf('
1053
				SELECT * FROM missed_parts
1054
				WHERE groups_id = %d AND attempts < %d
1055
				ORDER BY numberid ASC LIMIT %d', $groupArr['id'], $this->_partRepairMaxTries, $this->_partRepairLimit));
1056
        } catch (\PDOException $e) {
1057
            if ($e->getMessage() === 'SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction') {
1058
                $this->colorCli->notice('Deadlock occurred');
1059
                DB::rollBack();
1060
            }
1061
        }
1062
1063
        $missingCount = \count($missingParts);
1064
        if ($missingCount > 0) {
1065
            if ($this->_echoCLI) {
1066
                $this->colorCli->primary(
1067
                        'Attempting to repair '.
1068
                        number_format($missingCount).
1069
                        ' parts.'
1070
                    );
1071
            }
1072
1073
            // Loop through each part to group into continuous ranges with a maximum range of messagebuffer/4.
1074
            $ranges = $partList = [];
1075
            $firstPart = $lastNum = $missingParts[0]->numberid;
1076
1077
            foreach ($missingParts as $part) {
1078
                if (($part->numberid - $firstPart) > ($this->messageBuffer / 4)) {
1079
                    $ranges[] = [
1080
                        'partfrom' => $firstPart,
1081
                        'partto'   => $lastNum,
1082
                        'partlist' => $partList,
1083
                    ];
1084
1085
                    $firstPart = $part->numberid;
1086
                    $partList = [];
1087
                }
1088
                $partList[] = $part->numberid;
1089
                $lastNum = $part->numberid;
1090
            }
1091
1092
            $ranges[] = [
1093
                'partfrom' => $firstPart,
1094
                'partto'   => $lastNum,
1095
                'partlist' => $partList,
1096
            ];
1097
1098
            // Download missing parts in ranges.
1099
            foreach ($ranges as $range) {
1100
                $partFrom = $range['partfrom'];
1101
                $partTo = $range['partto'];
1102
                $partList = $range['partlist'];
1103
1104
                if ($this->_echoCLI) {
1105
                    echo \chr(random_int(45, 46)).PHP_EOL;
1106
                }
1107
1108
                // Get article headers from newsgroup.
1109
                $this->scan($groupArr, $partFrom, $partTo, 'missed_parts', $partList);
1110
            }
1111
1112
            // Calculate parts repaired
1113
            $result = DB::select(
1114
                sprintf(
1115
                    '
1116
					SELECT COUNT(id) AS num
1117
					FROM missed_parts
1118
					WHERE groups_id = %d
1119
					AND numberid <= %d',
1120
                    $groupArr['id'],
1121
                    $missingParts[$missingCount - 1]->numberid
1122
                )
1123
            );
1124
1125
            $partsRepaired = 0;
1126
            if ($result > 0) {
1127
                $partsRepaired = ($missingCount - $result[0]->num);
1128
            }
1129
1130
            // Update attempts on remaining parts for active group
1131
            if (isset($missingParts[$missingCount - 1]->id)) {
1132
                DB::update(
1133
                    sprintf(
1134
                        '
1135
						UPDATE missed_parts
1136
						SET attempts = attempts + 1
1137
						WHERE groups_id = %d
1138
						AND numberid <= %d',
1139
                        $groupArr['id'],
1140
                        $missingParts[$missingCount - 1]->numberid
1141
                    )
1142
                );
1143
            }
1144
1145
            if ($this->_echoCLI) {
1146
                $this->colorCli->primary(
1147
                        PHP_EOL.
1148
                        number_format($partsRepaired).
1149
                        ' parts repaired.'
1150
                    );
1151
            }
1152
        }
1153
1154
        // Remove articles that we cant fetch after x attempts.
1155
        DB::transaction(function () use ($groupArr) {
1156
            DB::delete(
1157
                sprintf(
1158
                    'DELETE FROM missed_parts WHERE attempts >= %d AND groups_id = %d',
1159
                    $this->_partRepairMaxTries,
1160
                    $groupArr['id']
1161
                )
1162
            );
1163
        }, 10);
1164
    }
1165
1166
    /**
1167
     * Returns unix time for an article number.
1168
     *
1169
     * @param int   $post      The article number to get the time from.
1170
     * @param array $groupData Usenet group info from NNTP selectGroup method.
1171
     *
1172
     * @return int    Timestamp.
1173
     * @throws \Exception
1174
     */
1175
    public function postdate($post, array $groupData): int
1176
    {
1177
        $currentPost = $post;
1178
1179
        $attempts = $date = 0;
1180
        do {
1181
            // Try to get the article date locally first.
1182
            // Try to get locally.
1183
            $local = DB::select(
1184
                    sprintf(
1185
                        '
1186
						SELECT c.date AS date
1187
						FROM collections c
1188
						INNER JOIN binaries b ON(c.id=b.collections_id)
1189
						INNER JOIN parts p ON(b.id=p.binaries_id)
1190
						WHERE p.number = %s',
1191
                        $currentPost
1192
                    )
1193
                );
1194
            if (! empty($local) && \count($local) > 0) {
1195
                $date = $local[0]->date;
1196
                break;
1197
            }
1198
1199
            // If we could not find it locally, try usenet.
1200
            $header = $this->_nntp->getXOVER($currentPost);
1201
            if (! $this->_nntp->isError($header) && isset($header[0]['Date']) && $header[0]['Date'] !== '') {
1202
                $date = $header[0]['Date'];
1203
                break;
1204
            }
1205
1206
            // Try to get a different article number.
1207
            if (abs($currentPost - $groupData['first']) > abs($groupData['last'] - $currentPost)) {
1208
                $tempPost = round($currentPost / (random_int(1005, 1012) / 1000), 0, PHP_ROUND_HALF_UP);
1209
                if ($tempPost < $groupData['first']) {
1210
                    $tempPost = $groupData['first'];
1211
                }
1212
            } else {
1213
                $tempPost = round((random_int(1005, 1012) / 1000) * $currentPost, 0, PHP_ROUND_HALF_UP);
1214
                if ($tempPost > $groupData['last']) {
1215
                    $tempPost = $groupData['last'];
1216
                }
1217
            }
1218
            // If we got the same article number as last time, give up.
1219
            if ($tempPost === $currentPost) {
1220
                break;
1221
            }
1222
            $currentPost = $tempPost;
1223
        } while ($attempts++ <= 20);
1224
1225
        // If we didn't get a date, set it to now.
1226
        if (! $date) {
1227
            $date = time();
1228
        } else {
1229
            $date = strtotime($date);
1230
        }
1231
1232
        return $date;
1233
    }
1234
1235
    /**
1236
     * Returns article number based on # of days.
1237
     *
1238
     * @param int   $days How many days back we want to go.
1239
     * @param array $data Group data from usenet.
1240
     *
1241
     * @return string
1242
     * @throws \Exception
1243
     */
1244
    public function daytopost($days, $data): string
1245
    {
1246
        $goalTime = now()->subDays($days)->timestamp;
1247
        // The time we want = current unix time (ex. 1395699114) - minus 86400 (seconds in a day)
1248
        // times days wanted. (ie 1395699114 - 2592000 (30days)) = 1393107114
1249
1250
        // The servers oldest date.
1251
        $firstDate = $this->postdate($data['first'], $data);
1252
        if ($goalTime < $firstDate) {
1253
            // If the date we want is older than the oldest date in the group return the groups oldest article.
1254
            return $data['first'];
1255
        }
1256
1257
        // The servers newest date.
1258
        $lastDate = $this->postdate($data['last'], $data);
1259
        if ($goalTime > $lastDate) {
1260
            // If the date we want is newer than the groups newest date, return the groups newest article.
1261
            return $data['last'];
1262
        }
1263
1264
        if ($this->_echoCLI) {
1265
            $this->colorCli->primary(
1266
                    'Searching for an approximate article number for group '.$data['group'].' '.$days.' days back.'
1267
                );
1268
        }
1269
1270
        // Pick the middle to start with
1271
        $wantedArticle = round(($data['last'] + $data['first']) / 2);
1272
        $aMax = $data['last'];
1273
        $aMin = $data['first'];
1274
        $oldArticle = $articleTime = null;
1275
1276
        while (true) {
1277
            // Article exists outside of available range, this shouldn't happen
1278
            if ($wantedArticle <= $data['first'] || $wantedArticle >= $data['last']) {
1279
                break;
1280
            }
1281
1282
            // Keep a note of the last articles we checked
1283
            $reallyOldArticle = $oldArticle;
1284
            $oldArticle = $wantedArticle;
1285
1286
            // Get the date of this article
1287
            $articleTime = $this->postdate($wantedArticle, $data);
0 ignored issues
show
Bug introduced by
$wantedArticle of type double is incompatible with the type integer expected by parameter $post of Blacklight\Binaries::postdate(). ( Ignorable by Annotation )

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

1287
            $articleTime = $this->postdate(/** @scrutinizer ignore-type */ $wantedArticle, $data);
Loading history...
1288
1289
            // Article doesn't exist, start again with something random
1290
            if (! $articleTime) {
1291
                $wantedArticle = random_int($aMin, $aMax);
1292
                $articleTime = $this->postdate($wantedArticle, $data);
1293
            }
1294
1295
            if ($articleTime < $goalTime) {
1296
                // Article is older than we want
1297
                $aMin = $oldArticle;
1298
                $wantedArticle = round(($aMax + $oldArticle) / 2);
1299
                if ($this->_echoCLI) {
1300
                    echo '-';
1301
                }
1302
            } elseif ($articleTime > $goalTime) {
1303
                // Article is newer than we want
1304
                $aMax = $oldArticle;
1305
                $wantedArticle = round(($aMin + $oldArticle) / 2);
1306
                if ($this->_echoCLI) {
1307
                    echo '+';
1308
                }
1309
            } elseif ($articleTime === $goalTime) {
1310
                // Exact match. We did it! (this will likely never happen though)
1311
                break;
1312
            }
1313
1314
            // We seem to be flip-flopping between 2 articles, assume we're out of articles to check.
1315
            // End on an article more recent than our oldest so that we don't miss any releases.
1316
            if ($reallyOldArticle === $wantedArticle && ($goalTime - $articleTime) <= 0) {
1317
                break;
1318
            }
1319
        }
1320
1321
        $wantedArticle = (int) $wantedArticle;
1322
        if ($this->_echoCLI) {
1323
            $this->colorCli->primary(
1324
                    PHP_EOL.'Found article #'.$wantedArticle.' which has a date of '.date('r', $articleTime).
1325
                    ', vs wanted date of '.date('r', $goalTime).'. Difference from goal is '.Carbon::createFromTimestamp($goalTime)->diffInDays(Carbon::createFromTimestamp($articleTime)).'days.'
1326
                );
1327
        }
1328
1329
        return $wantedArticle;
1330
    }
1331
1332
    /**
1333
     * Add article numbers from missing headers to DB.
1334
     *
1335
     * @param array $numbers The article numbers of the missing headers.
1336
     * @param int $groupID The ID of this groups.
1337
     *
1338
     *
1339
     * @return string
1340
     */
1341
    private function addMissingParts($numbers, $groupID): string
1342
    {
1343
        $insertStr = 'INSERT INTO missed_parts (numberid, groups_id) VALUES ';
1344
        foreach ($numbers as $number) {
1345
            $insertStr .= '('.$number.','.$groupID.'),';
1346
        }
1347
1348
        DB::insert(rtrim($insertStr, ',').' ON DUPLICATE KEY UPDATE attempts=attempts+1');
1349
1350
        return $this->_pdo->lastInsertId();
1351
    }
1352
1353
    /**
1354
     * Clean up part repair table.
1355
     *
1356
     * @param array  $numbers   The article numbers.
1357
     * @param int    $groupID   The ID of the group.
1358
     *
1359
     * @return void
1360
     * @throws \Throwable
1361
     */
1362
    private function removeRepairedParts(array $numbers, $groupID): void
1363
    {
1364
        $sql = 'DELETE FROM missed_parts WHERE numberid in (';
1365
        foreach ($numbers as $number) {
1366
            $sql .= $number.',';
1367
        }
1368
        DB::transaction(function () use ($groupID, $sql) {
1369
            DB::delete(rtrim($sql, ',').') AND groups_id = '.$groupID);
1370
        }, 10);
1371
    }
1372
1373
    /**
1374
     * Are white or black lists loaded for a group name?
1375
     * @var array
1376
     */
1377
    protected $_listsFound = [];
1378
1379
    /**
1380
     * Get blacklist and cache it. Return if already cached.
1381
     *
1382
     * @param string $groupName
1383
     *
1384
     * @return void
1385
     */
1386
    protected function _retrieveBlackList($groupName): void
1387
    {
1388
        if (! isset($this->blackList[$groupName])) {
1389
            $this->blackList[$groupName] = $this->getBlacklist(true, self::OPTYPE_BLACKLIST, $groupName, true);
1390
        }
1391
        if (! isset($this->whiteList[$groupName])) {
1392
            $this->whiteList[$groupName] = $this->getBlacklist(true, self::OPTYPE_WHITELIST, $groupName, true);
1393
        }
1394
        $this->_listsFound[$groupName] = ($this->blackList[$groupName] || $this->whiteList[$groupName]);
1395
    }
1396
1397
    /**
1398
     * Check if an article is blacklisted.
1399
     *
1400
     * @param array  $msg       The article header (OVER format).
1401
     * @param string $groupName The group name.
1402
     *
1403
     * @return bool
1404
     */
1405
    public function isBlackListed($msg, $groupName): bool
1406
    {
1407
        if (! isset($this->_listsFound[$groupName])) {
1408
            $this->_retrieveBlackList($groupName);
1409
        }
1410
        if (! $this->_listsFound[$groupName]) {
1411
            return false;
1412
        }
1413
1414
        $blackListed = false;
1415
1416
        $field = [
1417
            self::BLACKLIST_FIELD_SUBJECT   => $msg['Subject'],
1418
            self::BLACKLIST_FIELD_FROM      => $msg['From'],
1419
            self::BLACKLIST_FIELD_MESSAGEID => $msg['Message-ID'],
1420
        ];
1421
1422
        // Try white lists first.
1423
        if ($this->whiteList[$groupName]) {
1424
            // There are white lists for this group, so anything that doesn't match a white list should be considered black listed.
1425
            $blackListed = true;
1426
            foreach ($this->whiteList[$groupName] as $whiteList) {
1427
                if (preg_match('/'.$whiteList['regex'].'/i', $field[$whiteList['msgcol']])) {
1428
                    // This field matched a white list, so it might not be black listed.
1429
                    $blackListed = false;
1430
                    $this->_binaryBlacklistIdsToUpdate[$whiteList['id']] = $whiteList['id'];
1431
                    break;
1432
                }
1433
            }
1434
        }
1435
1436
        // Check if the field is black listed.
1437
1438
        if (! $blackListed && $this->blackList[$groupName]) {
1439
            foreach ($this->blackList[$groupName] as $blackList) {
1440
                if (preg_match('/'.$blackList->regex.'/i', $field[$blackList->msgcol])) {
1441
                    $blackListed = true;
1442
                    $this->_binaryBlacklistIdsToUpdate[$blackList->id] = $blackList->id;
1443
                    break;
1444
                }
1445
            }
1446
        }
1447
1448
        return $blackListed;
1449
    }
1450
1451
    /**
1452
     * Return all blacklists.
1453
     *
1454
     * @param bool   $activeOnly Only display active blacklists ?
1455
     * @param int|string    $opType     Optional, get white or black lists (use Binaries constants).
1456
     * @param string $groupName  Optional, group.
1457
     * @param bool   $groupRegex Optional Join groups / binaryblacklist using regexp for equals.
1458
     *
1459
     * @return array
1460
     */
1461
    public function getBlacklist($activeOnly = true, $opType = -1, $groupName = '', $groupRegex = false): array
1462
    {
1463
        switch ($opType) {
1464
            case self::OPTYPE_BLACKLIST:
1465
                $opType = 'AND bb.optype = '.self::OPTYPE_BLACKLIST;
1466
                break;
1467
            case self::OPTYPE_WHITELIST:
1468
                $opType = 'AND bb.optype = '.self::OPTYPE_WHITELIST;
1469
                break;
1470
            default:
1471
                $opType = '';
1472
                break;
1473
        }
1474
1475
        return DB::select(
1476
            sprintf(
1477
                '
1478
				SELECT
1479
					bb.id, bb.optype, bb.status, bb.description,
1480
					bb.groupname AS groupname, bb.regex, g.id AS group_id, bb.msgcol,
1481
					bb.last_activity as last_activity
1482
				FROM binaryblacklist bb
1483
				LEFT OUTER JOIN groups g ON g.name %s bb.groupname
1484
				WHERE 1=1 %s %s %s
1485
				ORDER BY coalesce(groupname,\'zzz\')',
1486
                ($groupRegex ? 'REGEXP' : '='),
1487
                ($activeOnly ? 'AND bb.status = 1' : ''),
1488
                $opType,
1489
                ($groupName ? ('AND g.name REGEXP '.escapeString($groupName)) : '')
1490
            )
1491
        );
1492
    }
1493
1494
    /**
1495
     * Return the specified blacklist.
1496
     *
1497
     * @param int $id The blacklist ID.
1498
     *
1499
     * @return \Illuminate\Database\Eloquent\Model|null|static
1500
     */
1501
    public function getBlacklistByID($id)
1502
    {
1503
        return BinaryBlacklist::query()->where('id', $id)->first();
1504
    }
1505
1506
    /**
1507
     * Delete a blacklist.
1508
     *
1509
     * @param int $id The ID of the blacklist.
1510
     */
1511
    public function deleteBlacklist($id): void
1512
    {
1513
        BinaryBlacklist::query()->where('id', $id)->delete();
1514
    }
1515
1516
    /**
1517
     * @param $blacklistArray
1518
     */
1519
    public function updateBlacklist($blacklistArray): void
1520
    {
1521
        BinaryBlacklist::query()->where('id', $blacklistArray['id'])->update(
1522
            [
1523
                'groupname' => $blacklistArray['groupname'] === '' ? 'null' : preg_replace('/a\.b\./i', 'alt.binaries.', $blacklistArray['groupname']),
1524
                'regex' => $blacklistArray['regex'],
1525
                'status' => $blacklistArray['status'],
1526
                'description' => $blacklistArray['description'],
1527
                'optype' => $blacklistArray['optype'],
1528
                'msgcol' => $blacklistArray['msgcol'],
1529
            ]
1530
        );
1531
    }
1532
1533
    /**
1534
     * Adds a new blacklist from binary blacklist edit admin web page.
1535
     *
1536
     * @param array $blacklistArray
1537
     */
1538
    public function addBlacklist($blacklistArray): void
1539
    {
1540
        BinaryBlacklist::query()->insert(
1541
            [
1542
                'groupname' => $blacklistArray['groupname'] === '' ? 'null' : preg_replace('/a\.b\./i', 'alt.binaries.', $blacklistArray['groupname']),
1543
                'regex' => $blacklistArray['regex'],
1544
                'status' => $blacklistArray['status'],
1545
                'description' => $blacklistArray['description'],
1546
                'optype' => $blacklistArray['optype'],
1547
                'msgcol' => $blacklistArray['msgcol'],
1548
            ]
1549
        );
1550
    }
1551
1552
    /**
1553
     * Delete Collections/Binaries/Parts for a Collection ID.
1554
     *
1555
     * @param int $collectionID Collections table ID
1556
     *
1557
     * @note A trigger automatically deletes the parts/binaries.
1558
     *
1559
     * @return void
1560
     * @throws \Throwable
1561
     */
1562
    public function delete($collectionID): void
1563
    {
1564
        DB::transaction(function () use ($collectionID) {
1565
            DB::delete(sprintf('DELETE FROM collections WHERE id = %d', $collectionID));
1566
        }, 10);
1567
    }
1568
1569
    /**
1570
     * Log / Echo message.
1571
     *
1572
     * @param string $message Message to log.
1573
     * @param string $method  Method that called this.
1574
     * @param string $color   ColorCLI method name.
1575
     */
1576
    private function log($message, $method, $color): void
1577
    {
1578
        if ($this->_echoCLI) {
1579
            $this->colorCli->$color($message.' ['.__CLASS__."::$method]");
1580
        }
1581
    }
1582
1583
    /**
1584
     * Check if we should ignore the file count and return true or false.
1585
     *
1586
     * @param string $groupName
1587
     * @param string $subject
1588
     *
1589
     * @return bool
1590
     */
1591
    protected function _ignoreFileCount($groupName, $subject): bool
1592
    {
1593
        $ignore = false;
1594
        switch ($groupName) {
1595
            case 'alt.binaries.erotica':
1596
                if (preg_match('/^\[\d+\]-\[FULL\]-\[#a\.b\.erotica@EFNet\]-\[ \d{2,3}_/', $subject)) {
1597
                    $ignore = true;
1598
                }
1599
                break;
1600
        }
1601
1602
        return $ignore;
1603
    }
1604
1605
    /**
1606
     * @param $query
1607
     *
1608
     * @return bool
1609
     */
1610
    protected function runQuery($query)
1611
    {
1612
        try {
1613
            return DB::insert($query);
1614
        } catch (QueryException $e) {
1615
            if (config('app.debug' === true)) {
1616
                Log::error($e->getMessage());
1617
            }
1618
            $this->colorCli->debug('Query error occurred.');
1619
        } catch (\PDOException $e) {
1620
            if (config('app.debug' === true)) {
1621
                Log::error($e->getMessage());
1622
            }
1623
            $this->colorCli->debug('Query error occurred.');
1624
        } catch (\Throwable $e) {
1625
            if (config('app.debug' === true)) {
1626
                Log::error($e->getMessage());
1627
            }
1628
            $this->colorCli->debug('Query error occurred.');
1629
        }
1630
1631
        return false;
1632
    }
1633
}
1634