Completed
Branch dev (4bcb34)
by Darko
13:52
created

Binaries::__construct()   F

Complexity

Conditions 11
Paths 1024

Size

Total Lines 33
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 26
nc 1024
nop 1
dl 0
loc 33
rs 3.1764
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Blacklight;
4
5
use App\Models\Group;
6
use Blacklight\db\DB;
7
use App\Models\Settings;
8
use Illuminate\Support\Carbon;
9
use App\Models\BinaryBlacklist;
10
use App\Models\MultigroupPoster;
11
use Illuminate\Support\Facades\Cache;
12
use Blacklight\processing\ProcessReleasesMultiGroup;
13
14
/**
15
 * Class Binaries.
16
 */
17
class Binaries
18
{
19
    public const OPTYPE_BLACKLIST = 1;
20
    public const OPTYPE_WHITELIST = 2;
21
22
    public const BLACKLIST_DISABLED = 0;
23
    public const BLACKLIST_ENABLED = 1;
24
25
    public const BLACKLIST_FIELD_SUBJECT = 1;
26
    public const BLACKLIST_FIELD_FROM = 2;
27
    public const BLACKLIST_FIELD_MESSAGEID = 3;
28
29
    /**
30
     * @var array
31
     */
32
    public $blackList = [];
33
34
    /**
35
     * @var array
36
     */
37
    public $whiteList = [];
38
39
    /**
40
     * @var int
41
     */
42
    public $messageBuffer;
43
44
    /**
45
     * @var \Blacklight\ColorCLI
46
     */
47
    protected $_colorCLI;
48
49
    /**
50
     * @var \Blacklight\CollectionsCleaning
51
     */
52
    protected $_collectionsCleaning;
53
54
    /**
55
     * @var \Blacklight\NNTP
56
     */
57
    protected $_nntp;
58
59
    /**
60
     * Should we use header compression?
61
     *
62
     * @var bool
63
     */
64
    protected $_compressedHeaders;
65
66
    /**
67
     * Should we use part repair?
68
     *
69
     * @var bool
70
     */
71
    protected $_partRepair;
72
73
    /**
74
     * @var \Blacklight\db\DB
75
     */
76
    protected $_pdo;
77
78
    /**
79
     * How many days to go back on a new group?
80
     *
81
     * @var bool
82
     */
83
    protected $_newGroupScanByDays;
84
85
    /**
86
     * How many headers to download on new groups?
87
     *
88
     * @var int
89
     */
90
    protected $_newGroupMessagesToScan;
91
92
    /**
93
     * How many days to go back on new groups?
94
     *
95
     * @var int
96
     */
97
    protected $_newGroupDaysToScan;
98
99
    /**
100
     * How many headers to download per run of part repair?
101
     *
102
     * @var int
103
     */
104
    protected $_partRepairLimit;
105
106
    /**
107
     * Should we show dropped yEnc to CLI?
108
     *
109
     * @var bool
110
     */
111
    protected $_showDroppedYEncParts;
112
113
    /**
114
     * Echo to cli?
115
     *
116
     * @var bool
117
     */
118
    protected $_echoCLI;
119
120
    /**
121
     * @var bool
122
     */
123
    protected $_debug = false;
124
125
    /**
126
     * Max tries to download headers.
127
     * @var int
128
     */
129
    protected $_partRepairMaxTries;
130
131
    /**
132
     * An array of binaryblacklist IDs that should have their activity date updated.
133
     * @var array(int)
134
     */
135
    protected $_binaryBlacklistIdsToUpdate = [];
136
137
    /**
138
     * @var float microseconds time of cleaning process start
139
     */
140
    protected $startCleaning;
141
142
    /**
143
     * @var float microseconds time of the start of the scan function
144
     */
145
    protected $startLoop;
146
147
    /**
148
     * @var bool Is this retrieved header a multigroup one?
149
     */
150
    protected $multiGroup;
151
152
    /**
153
     * @var bool
154
     */
155
    protected $allAsMgr;
156
157
    /**
158
     * @var string How long it took in seconds to download headers
159
     */
160
    protected $timeHeaders;
161
162
    /**
163
     * @var string How long it took in seconds to clean/parse headers
164
     */
165
    protected $timeCleaning;
166
167
    /**
168
     * @var float microseconds time part repair was started
169
     */
170
    protected $startPR;
171
172
    /**
173
     * @var array The CBP/MGR tables names
174
     */
175
    protected $tableNames;
176
177
    /**
178
     * @var float microseconds time header update was started
179
     */
180
    protected $startUpdate;
181
182
    /**
183
     * @var string The time it took to insert the headers
184
     */
185
    protected $timeInsert;
186
187
    /**
188
     * @var array the header currently being scanned
189
     */
190
    protected $header;
191
192
    /**
193
     * @var bool Should we add parts to part repair queue?
194
     */
195
    protected $addToPartRepair;
196
197
    /**
198
     * @var array Numbers of Headers received from the USP
199
     */
200
    protected $headersReceived;
201
202
    /**
203
     * @var array The current newsgroup information being updated
204
     */
205
    protected $groupMySQL;
206
207
    /**
208
     * @var int the last article number in the range
209
     */
210
    protected $last;
211
212
    /**
213
     * @var int the first article number in the range
214
     */
215
    protected $first;
216
217
    /**
218
     * @var int How many received headers were not yEnc encoded
219
     */
220
    protected $notYEnc;
221
222
    /**
223
     * @var int How many received headers were blacklist matched
224
     */
225
    protected $headersBlackListed;
226
227
    /**
228
     * @var array Header numbers that were not inserted
229
     */
230
    protected $headersNotInserted;
231
232
    /**
233
     * Constructor.
234
     *
235
     * @param array $options Class instances / echo to CLI?
236
     *
237
     * @throws \Exception
238
     */
239
    public function __construct(array $options = [])
240
    {
241
        $defaults = [
242
            'Echo'                => true,
243
            'CollectionsCleaning' => null,
244
            'ColorCLI'            => null,
245
            'Logger'              => null,
246
            'Groups'              => null,
247
            'NNTP'                => null,
248
            'Settings'            => null,
249
        ];
250
        $options += $defaults;
251
252
        $this->_echoCLI = ($options['Echo'] && config('nntmux.echocli'));
253
254
        $this->_pdo = ($options['Settings'] instanceof DB ? $options['Settings'] : new DB());
255
        $this->_colorCLI = ($options['ColorCLI'] instanceof ColorCLI ? $options['ColorCLI'] : new ColorCLI());
256
        $this->_nntp = ($options['NNTP'] instanceof NNTP ? $options['NNTP'] : new NNTP(['Echo' => $this->_colorCLI, 'Settings' => $this->_pdo, 'ColorCLI' => $this->_colorCLI]));
257
        $this->_collectionsCleaning = ($options['CollectionsCleaning'] instanceof CollectionsCleaning ? $options['CollectionsCleaning'] : new CollectionsCleaning(['Settings' => $this->_pdo]));
258
259
        $this->messageBuffer = Settings::settingValue('..maxmssgs') !== '' ?
0 ignored issues
show
Bug introduced by
'..maxmssgs' of type string is incompatible with the type boolean|array expected by parameter $setting of App\Models\Settings::settingValue(). ( Ignorable by Annotation )

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

259
        $this->messageBuffer = Settings::settingValue(/** @scrutinizer ignore-type */ '..maxmssgs') !== '' ?
Loading history...
260
            (int) Settings::settingValue('..maxmssgs') : 20000;
261
        $this->_compressedHeaders = (int) Settings::settingValue('..compressedheaders') === 1;
262
        $this->_partRepair = (int) Settings::settingValue('..partrepair') === 1;
263
        $this->_newGroupScanByDays = (int) Settings::settingValue('..newgroupscanmethod') === 1;
264
        $this->_newGroupMessagesToScan = Settings::settingValue('..newgroupmsgstoscan') !== '' ? (int) Settings::settingValue('..newgroupmsgstoscan') : 50000;
265
        $this->_newGroupDaysToScan = Settings::settingValue('..newgroupdaystoscan') !== '' ? (int) Settings::settingValue('..newgroupdaystoscan') : 3;
266
        $this->_partRepairLimit = Settings::settingValue('..maxpartrepair') !== '' ? (int) Settings::settingValue('..maxpartrepair') : 15000;
267
        $this->_partRepairMaxTries = (Settings::settingValue('..partrepairmaxtries') !== '' ? (int) Settings::settingValue('..partrepairmaxtries') : 3);
268
        $this->_showDroppedYEncParts = (int) Settings::settingValue('..showdroppedyencparts') === 1;
269
        $this->allAsMgr = (int) Settings::settingValue('..allasmgr') === 1;
270
271
        $this->blackList = $this->whiteList = [];
272
    }
273
274
    /**
275
     * Download new headers for all active groups.
276
     *
277
     * @param int $maxHeaders (Optional) How many headers to download max.
278
     *
279
     * @return void
280
     * @throws \Exception
281
     */
282
    public function updateAllGroups($maxHeaders = 100000): void
283
    {
284
        $groups = Group::getActive();
285
286
        $groupCount = \count($groups);
287
        if ($groupCount > 0) {
288
            $counter = 1;
289
            $allTime = microtime(true);
290
291
            $this->log(
292
                'Updating: '.$groupCount.' group(s) - Using compression? '.($this->_compressedHeaders ? 'Yes' : 'No'),
293
                __FUNCTION__,
294
                'header'
295
            );
296
297
            // Loop through groups.
298
            foreach ($groups as $group) {
299
                $this->log(
300
                    'Starting group '.$counter.' of '.$groupCount,
301
                    __FUNCTION__,
302
                    'header'
303
                );
304
                $this->updateGroup($group, $maxHeaders);
305
                $counter++;
306
            }
307
308
            $this->log(
309
                'Updating completed in '.number_format(microtime(true) - $allTime, 2).' seconds.',
310
                __FUNCTION__,
311
                'primary'
312
            );
313
        } else {
314
            $this->log(
315
                'No groups specified. Ensure groups are added to NNTmux\'s database for updating.',
316
                __FUNCTION__,
317
                'warning'
318
            );
319
        }
320
    }
321
322
    /**
323
     * When the indexer is started, log the date/time.
324
     */
325
    public function logIndexerStart(): void
326
    {
327
        Settings::query()->where('setting', '=', 'last_run_time')->update(['value' => Carbon::now()]);
328
    }
329
330
    /**
331
     * Download new headers for a single group.
332
     *
333
     * @param array $groupMySQL Array of MySQL results for a single group.
334
     * @param int   $maxHeaders (Optional) How many headers to download max.
335
     *
336
     * @return void
337
     * @throws \Exception
338
     */
339
    public function updateGroup($groupMySQL, $maxHeaders = 0): void
340
    {
341
        $startGroup = microtime(true);
342
343
        $this->logIndexerStart();
344
345
        // Select the group on the NNTP server, gets the latest info on it.
346
        $groupNNTP = $this->_nntp->selectGroup($groupMySQL['name']);
347
        if ($this->_nntp->isError($groupNNTP)) {
348
            $groupNNTP = $this->_nntp->dataError($this->_nntp, $groupMySQL['name']);
349
350
            if (isset($groupNNTP['code']) && (int) $groupNNTP['code'] === 411) {
351
                Group::disableIfNotExist($groupMySQL['id']);
352
            }
353
            if ($this->_nntp->isError($groupNNTP)) {
354
                return;
355
            }
356
        }
357
358
        if ($this->_echoCLI) {
359
            ColorCLI::doEcho(ColorCLI::primary('Processing '.$groupMySQL['name']), true);
360
        }
361
362
        // Attempt to repair any missing parts before grabbing new ones.
363
        if ((int) $groupMySQL['last_record'] !== 0) {
364
            if ($this->_partRepair) {
365
                if ($this->_echoCLI) {
366
                    ColorCLI::doEcho(ColorCLI::primary('Part repair enabled. Checking for missing parts.'), true);
367
                }
368
                $this->partRepair($groupMySQL);
369
370
                $mgrPosters = $this->getMultiGroupPosters();
371
                if ($this->allAsMgr === true || ! empty($mgrPosters)) {
372
                    $tableNames = ProcessReleasesMultiGroup::tableNames();
373
                    $this->partRepair($groupMySQL, $tableNames);
374
                }
375
            } elseif ($this->_echoCLI) {
376
                ColorCLI::doEcho(ColorCLI::primary('Part repair disabled by user.'), true);
377
            }
378
        }
379
380
        // Generate postdate for first record, for those that upgraded.
381
        if ($groupMySQL['first_record_postdate'] === null && (int) $groupMySQL['first_record'] !== 0) {
382
            $groupMySQL['first_record_postdate'] = $this->postdate($groupMySQL['first_record'], $groupNNTP);
383
            Group::query()->where('id', $groupMySQL['id'])->update(['first_record_postdate' => Carbon::createFromTimestamp($groupMySQL['first_record_postdate'])]);
384
        }
385
386
        // Get first article we want aka the oldest.
387
        if ((int) $groupMySQL['last_record'] === 0) {
388
            if ($this->_newGroupScanByDays) {
389
                // For new newsgroups - determine here how far we want to go back using date.
390
                $first = $this->daytopost($this->_newGroupDaysToScan, $groupNNTP);
391
            } elseif ($groupNNTP['first'] >= ($groupNNTP['last'] - ($this->_newGroupMessagesToScan + $this->messageBuffer))) {
392
                // If what we want is lower than the groups first article, set the wanted first to the first.
393
                $first = $groupNNTP['first'];
394
            } else {
395
                // Or else, use the newest article minus how much we should get for new groups.
396
                $first = (string) ($groupNNTP['last'] - ($this->_newGroupMessagesToScan + $this->messageBuffer));
397
            }
398
399
            // We will use this to subtract so we leave articles for the next time (in case the server doesn't have them yet)
400
            $leaveOver = $this->messageBuffer;
401
402
        // If this is not a new group, go from our newest to the servers newest.
403
        } else {
404
            // Set our oldest wanted to our newest local article.
405
            $first = $groupMySQL['last_record'];
406
407
            // This is how many articles we will grab. (the servers newest minus our newest).
408
            $totalCount = (string) ($groupNNTP['last'] - $first);
409
410
            // Check if the server has more articles than our loop limit x 2.
411
            if ($totalCount > ($this->messageBuffer * 2)) {
412
                // Get the remainder of $totalCount / $this->message buffer
413
                $leaveOver = round($totalCount % $this->messageBuffer, 0, PHP_ROUND_HALF_DOWN) + $this->messageBuffer;
414
            } else {
415
                // Else get half of the available.
416
                $leaveOver = round($totalCount / 2, 0, PHP_ROUND_HALF_DOWN);
417
            }
418
        }
419
420
        // The last article we want, aka the newest.
421
        $last = $groupLast = (string) ($groupNNTP['last'] - $leaveOver);
422
423
        // If the newest we want is older than the oldest we want somehow.. set them equal.
424
        if ($last < $first) {
425
            $last = $groupLast = $first;
426
        }
427
428
        // This is how many articles we are going to get.
429
        $total = (string) ($groupLast - $first);
430
        // This is how many articles are available (without $leaveOver).
431
        $realTotal = (string) ($groupNNTP['last'] - $first);
432
433
        // Check if we should limit the amount of fetched new headers.
434
        if ($maxHeaders > 0) {
435
            if ($maxHeaders < ($groupLast - $first)) {
436
                $groupLast = $last = (string) ($first + $maxHeaders);
437
            }
438
            $total = (string) ($groupLast - $first);
439
        }
440
441
        // If total is bigger than 0 it means we have new parts in the newsgroup.
442
        if ($total > 0) {
443
            if ($this->_echoCLI) {
444
                ColorCLI::doEcho(
445
                    ColorCLI::primary(
446
                        (
447
                            (int) $groupMySQL['last_record'] === 0
448
                            ? 'New group '.$groupNNTP['group'].' starting with '.
449
                            (
450
                                $this->_newGroupScanByDays
451
                                ? $this->_newGroupDaysToScan.' days'
452
                                : number_format($this->_newGroupMessagesToScan).' messages'
453
                            ).' worth.'
454
                            : '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

454
                            : 'Group '.$groupNNTP['group'].' has '.number_format(/** @scrutinizer ignore-type */ $realTotal).' new articles.'
Loading history...
455
                        ).
456
                        ' Leaving '.number_format($leaveOver).
457
                        " for next pass.\nServer oldest: ".number_format($groupNNTP['first']).
458
                        ' Server newest: '.number_format($groupNNTP['last']).
459
                        ' Local newest: '.number_format($groupMySQL['last_record'])
460
                    ),
461
                    true
462
                );
463
            }
464
465
            $done = false;
466
            // Get all the parts (in portions of $this->messageBuffer to not use too much memory).
467
            while ($done === false) {
468
469
                // Increment last until we reach $groupLast (group newest article).
470
                if ($total > $this->messageBuffer) {
471
                    if ((string) ($first + $this->messageBuffer) > $groupLast) {
472
                        $last = $groupLast;
473
                    } else {
474
                        $last = (string) ($first + $this->messageBuffer);
475
                    }
476
                }
477
                // Increment first so we don't get an article we already had.
478
                $first++;
479
480
                if ($this->_echoCLI) {
481
                    ColorCLI::doEcho(
482
                        ColorCLI::header(
483
                            PHP_EOL.'Getting '.number_format($last - $first + 1).' articles ('.number_format($first).
484
                            ' to '.number_format($last).') from '.$groupMySQL['name'].' - ('.
485
                            number_format($groupLast - $last).' articles in queue).'
486
                        )
487
                    );
488
                }
489
490
                // Get article headers from newsgroup.
491
                $scanSummary = $this->scan($groupMySQL, $first, $last);
492
493
                // Check if we fetched headers.
494
                if (! empty($scanSummary)) {
495
496
                    // If new group, update first record & postdate
497
                    if ($groupMySQL['first_record_postdate'] === null && (int) $groupMySQL['first_record'] === 0) {
498
                        $groupMySQL['first_record'] = $scanSummary['firstArticleNumber'];
499
500
                        if (isset($scanSummary['firstArticleDate'])) {
501
                            $groupMySQL['first_record_postdate'] = strtotime($scanSummary['firstArticleDate']);
502
                        } else {
503
                            $groupMySQL['first_record_postdate'] = $this->postdate($groupMySQL['first_record'], $groupNNTP);
504
                        }
505
506
                        Group::query()
507
                            ->where('id', $groupMySQL['id'])
508
                            ->update(
509
                                [
510
                                    'first_record' => $scanSummary['firstArticleNumber'],
511
                                    'first_record_postdate' => Carbon::createFromTimestamp(
512
                                        $groupMySQL['first_record_postdate']
513
                                    ),
514
                                ]
515
                            );
516
                    }
517
518
                    $scanSummary['lastArticleDate'] = (isset($scanSummary['lastArticleDate']) ? strtotime($scanSummary['lastArticleDate']) : false);
519
                    if (! is_numeric($scanSummary['lastArticleDate'])) {
520
                        $scanSummary['lastArticleDate'] = $this->postdate($scanSummary['lastArticleNumber'], $groupNNTP);
521
                    }
522
523
                    Group::query()
524
                        ->where('id', $groupMySQL['id'])
525
                        ->update(
526
                            [
527
                                'last_record' => $scanSummary['lastArticleNumber'],
528
                                'last_record_postdate' => Carbon::createFromTimestamp($scanSummary['lastArticleDate']),
529
                                'last_updated' => Carbon::now(),
530
                            ]
531
                        );
532
                } else {
533
                    // If we didn't fetch headers, update the record still.
534
                    Group::query()
535
                        ->where('id', $groupMySQL['id'])
536
                        ->update(
537
                            [
538
                                'last_record' => $last,
539
                                'last_updated' => Carbon::now(),
540
                            ]
541
                        );
542
                }
543
544
                if ((int) $last === (int) $groupLast) {
545
                    $done = true;
546
                } else {
547
                    $first = $last;
548
                }
549
            }
550
551
            if ($this->_echoCLI) {
552
                ColorCLI::doEcho(
553
                    ColorCLI::primary(
554
                        PHP_EOL.'Group '.$groupMySQL['name'].' processed in '.
555
                        number_format(microtime(true) - $startGroup, 2).' seconds.'
556
                    ),
557
                    true
558
                );
559
            }
560
        } elseif ($this->_echoCLI) {
561
            ColorCLI::doEcho(
562
                ColorCLI::primary(
563
                    'No new articles for '.$groupMySQL['name'].' (first '.number_format($first).
564
                    ', last '.number_format($last).', grouplast '.number_format($groupMySQL['last_record']).
565
                    ', total '.number_format($total).")\n".'Server oldest: '.number_format($groupNNTP['first']).
566
                    ' Server newest: '.number_format($groupNNTP['last']).' Local newest: '.number_format($groupMySQL['last_record'])
567
                ),
568
                true
569
            );
570
        }
571
    }
572
573
    /**
574
     * Loop over range of wanted headers, insert headers into DB.
575
     *
576
     * @param array      $groupMySQL   The group info from mysql.
577
     * @param int        $first        The oldest wanted header.
578
     * @param int        $last         The newest wanted header.
579
     * @param string     $type         Is this partrepair or update or backfill?
580
     * @param null|array $missingParts If we are running in partrepair, the list of missing article numbers.
581
     *
582
     * @return array Empty on failure.
583
     * @throws \Exception
584
     */
585
    public function scan($groupMySQL, $first, $last, $type = 'update', $missingParts = null): array
586
    {
587
        // Start time of scan method and of fetching headers.
588
        $this->startLoop = microtime(true);
589
        $this->groupMySQL = $groupMySQL;
590
        $this->last = $last;
591
        $this->first = $first;
592
593
        $this->notYEnc = $this->headersBlackListed = 0;
594
595
        // Check if MySQL tables exist, create if they do not, get their names at the same time.
596
        $this->tableNames = (new Group())->getCBPTableNames($this->groupMySQL['id']);
597
598
        $mgrPosters = $this->getMultiGroupPosters();
599
600
        if ($this->allAsMgr === true || ! empty($mgrPosters)) {
601
            $mgrActive = true;
602
            $mgrPosters = ! empty($mgrPosters) ? array_flip(array_column($mgrPosters, 'poster')) : '';
603
        } else {
604
            $mgrActive = false;
605
        }
606
607
        $returnArray = $stdHeaders = $mgrHeaders = [];
608
609
        $partRepair = ($type === 'partrepair');
610
        $this->addToPartRepair = ($type === 'update' && $this->_partRepair);
611
612
        // Download the headers.
613
        if ($partRepair === true) {
614
            // This is slower but possibly is better with missing headers.
615
            $headers = $this->_nntp->getOverview($this->first.'-'.$this->last, true, false);
616
        } else {
617
            $headers = $this->_nntp->getXOVER($this->first.'-'.$this->last);
618
        }
619
620
        // If there was an error, try to reconnect.
621
        if ($this->_nntp->isError($headers)) {
622
623
            // Increment if part repair and return false.
624
            if ($partRepair === true) {
625
                $this->_pdo->queryExec(
626
                    sprintf(
627
                        'UPDATE %s SET attempts = attempts + 1 WHERE groups_id = %d AND numberid %s',
628
                        $this->tableNames['prname'],
629
                        $this->groupMySQL['id'],
630
                        ((int) $this->first === (int) $this->last ? '= '.$this->first : 'IN ('.implode(',', range($this->first, $this->last)).')')
631
                    )
632
                );
633
634
                return $returnArray;
635
            }
636
637
            // This is usually a compression error, so try disabling compression.
638
            $this->_nntp->doQuit();
639
            if ($this->_nntp->doConnect(false) !== true) {
640
                return $returnArray;
641
            }
642
643
            // Re-select group, download headers again without compression and re-enable compression.
644
            $this->_nntp->selectGroup($this->groupMySQL['name']);
645
            $headers = $this->_nntp->getXOVER($this->first.'-'.$this->last);
646
            $this->_nntp->enableCompression();
647
648
            // Check if the non-compression headers have an error.
649
            if ($this->_nntp->isError($headers)) {
650
                $message = ((int) $headers->code === 0 ? 'Unknown error' : $headers->message);
651
                $this->log(
652
                    "Code {$headers->code}: $message\nSkipping group: {$this->groupMySQL['name']}",
653
                    __FUNCTION__,
654
                    'error'
655
                );
656
657
                return $returnArray;
658
            }
659
        }
660
661
        // Start of processing headers.
662
        $this->startCleaning = microtime(true);
663
664
        // End of the getting data from usenet.
665
        $this->timeHeaders = number_format($this->startCleaning - $this->startLoop, 2);
666
667
        // Check if we got headers.
668
        $msgCount = \count($headers);
669
670
        if ($msgCount < 1) {
671
            return $returnArray;
672
        }
673
674
        $this->getHighLowArticleInfo($returnArray, $headers, $msgCount);
675
676
        $headersRepaired = $rangeNotReceived = $this->headersReceived = $this->headersNotInserted = [];
677
678
        foreach ($headers as $header) {
679
680
            // Check if we got the article or not.
681
            if (isset($header['Number'])) {
682
                $this->headersReceived[] = $header['Number'];
683
            } else {
684
                if ($this->addToPartRepair) {
685
                    $rangeNotReceived[] = $header['Number'];
686
                }
687
                continue;
688
            }
689
690
            // If set we are running in partRepair mode.
691
            if ($partRepair === true && $missingParts !== null) {
692
                if (! \in_array($header['Number'], $missingParts, false)) {
693
                    // If article isn't one that is missing skip it.
694
                    continue;
695
                }
696
                // We got the part this time. Remove article from part repair.
697
                $headersRepaired[] = $header['Number'];
698
            }
699
700
            /*
701
             * Find part / total parts. Ignore if no part count found.
702
             *
703
             * \s* Trims the leading space.
704
             * (?!"Usenet Index Post) ignores these types of articles, they are useless.
705
             * (.+) Fetches the subject.
706
             * \s+ Trims trailing space after the subject.
707
             * \((\d+)\/(\d+)\) Gets the part count.
708
             * No ending ($) as there are cases of subjects with extra data after the part count.
709
             */
710
            if (preg_match('/^\s*(?!"Usenet Index Post)(.+)\s+\((\d+)\/(\d+)\)/', $header['Subject'], $header['matches'])) {
711
                // Add yEnc to subjects that do not have them, but have the part number at the end of the header.
712
                if (stripos($header['Subject'], 'yEnc') === false) {
713
                    $header['matches'][1] .= ' yEnc';
714
                }
715
            } else {
716
                if ($this->_showDroppedYEncParts === true && strpos($header['Subject'], '"Usenet Index Post') !== 0) {
717
                    file_put_contents(
718
                        NN_LOGS.'not_yenc'.$this->groupMySQL['name'].'.dropped.log',
0 ignored issues
show
Bug introduced by
The constant Blacklight\NN_LOGS was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
719
                        $header['Subject'].PHP_EOL,
720
                        FILE_APPEND
721
                    );
722
                }
723
                $this->notYEnc++;
724
                continue;
725
            }
726
727
            // Filter subject based on black/white list.
728
            if ($this->isBlackListed($header, $this->groupMySQL['name'])) {
729
                $this->headersBlackListed++;
730
                continue;
731
            }
732
733
            if (! isset($header['Bytes'])) {
734
                $header['Bytes'] = (isset($this->header[':bytes']) ? $header[':bytes'] : 0);
735
            }
736
            $header['Bytes'] = (int) $header['Bytes'];
737
738
            if ($this->allAsMgr === true || ($mgrActive === true && array_key_exists($header['From'], $mgrPosters))) {
739
                $mgrHeaders[] = $header;
740
            } else {
741
                $stdHeaders[] = $header;
742
            }
743
        }
744
745
        unset($headers); // Reclaim memory now that headers are split.
746
747
        if (! empty($this->_binaryBlacklistIdsToUpdate)) {
748
            $this->updateBlacklistUsage();
749
        }
750
751
        if ($this->_echoCLI && $partRepair === false) {
752
            $this->outputHeaderInitial();
753
        }
754
755
        // MGR headers goes first
756
        if (! empty($mgrHeaders)) {
757
            $this->tableNames = ProcessReleasesMultiGroup::tableNames();
758
            $this->storeHeaders($mgrHeaders, true);
759
        }
760
        unset($mgrHeaders);
761
762
        // Standard headers go second so we can switch tableNames back and do part repair to standard group tables
763
        if (! empty($stdHeaders)) {
764
            $this->tableNames = (new Group())->getCBPTableNames($this->groupMySQL['id']);
765
            $this->storeHeaders($stdHeaders, false);
766
        }
767
        unset($stdHeaders);
768
769
        // Start of part repair.
770
        $this->startPR = microtime(true);
771
772
        // End of inserting.
773
        $this->timeInsert = number_format($this->startPR - $this->startUpdate, 2);
774
775
        if ($partRepair && \count($headersRepaired) > 0) {
776
            $this->removeRepairedParts($headersRepaired, $this->tableNames['prname'], $this->groupMySQL['id']);
777
        }
778
        unset($headersRepaired);
779
780
        if ($this->addToPartRepair) {
781
            $notInsertedCount = \count($this->headersNotInserted);
782
            if ($notInsertedCount > 0) {
783
                $this->addMissingParts($this->headersNotInserted, $this->tableNames['prname'], $this->groupMySQL['id']);
784
785
                $this->log(
786
                    $notInsertedCount.' articles failed to insert!',
787
                    __FUNCTION__,
788
                    'warning'
789
                );
790
            }
791
            unset($this->headersNotInserted);
792
793
            // Check if we have any missing headers.
794
            if (($this->last - $this->first - $this->notYEnc - $this->headersBlackListed + 1) > \count($this->headersReceived)) {
795
                $rangeNotReceived = array_merge($rangeNotReceived, array_diff(range($this->first, $this->last), $this->headersReceived));
796
            }
797
            $notReceivedCount = \count($rangeNotReceived);
798
            if ($notReceivedCount > 0) {
799
                $this->addMissingParts($rangeNotReceived, $this->tableNames['prname'], $this->groupMySQL['id']);
800
801
                if ($this->_echoCLI) {
802
                    ColorCLI::doEcho(
803
                        ColorCLI::alternate(
804
                            'Server did not return '.$notReceivedCount.
805
                            ' articles from '.$this->groupMySQL['name'].'.'
806
                        ),
807
                        true
808
                    );
809
                }
810
            }
811
            unset($rangeNotReceived);
812
        }
813
814
        $this->outputHeaderDuration();
815
816
        return $returnArray;
817
    }
818
819
    /**
820
     * Parse headers into collections/binaries and store header data as parts.
821
     *
822
     * @param array $headers    The retrieved headers
823
     * @param bool  $multiGroup Is this task being run in MGR mode?
824
     *
825
     * @throws \Exception
826
     */
827
    protected function storeHeaders(array $headers, $multiGroup): void
828
    {
829
        $this->multiGroup = $multiGroup;
830
        $binariesUpdate = $collectionIDs = $articles = [];
831
832
        $this->_pdo->beginTransaction();
833
834
        $partsQuery = $partsCheck =
835
            "INSERT IGNORE INTO {$this->tableNames['pname']} (binaries_id, number, messageid, partnumber, size) VALUES ";
836
837
        // Loop articles, figure out files/parts.
838
        foreach ($headers as $this->header) {
839
            // Set up the info for inserting into parts/binaries/collections tables.
840
            if (! isset($articles[$this->header['matches'][1]])) {
841
842
                // check whether file count should be ignored (XXX packs for now only).
843
                $whitelistMatch = false;
844
                if ($this->_ignoreFileCount($this->groupMySQL['name'], $this->header['matches'][1])) {
845
                    $whitelistMatch = true;
846
                    $fileCount[1] = $fileCount[3] = 0;
847
                }
848
849
                // Attempt to find the file count. If it is not found, set it to 0.
850
                if (! $whitelistMatch && ! preg_match('/[[(\s](\d{1,5})(\/|[\s_]of[\s_]|-)(\d{1,5})[])\s$:]/i', $this->header['matches'][1], $fileCount)) {
851
                    $fileCount[1] = $fileCount[3] = 0;
852
                    if ($this->_showDroppedYEncParts === true) {
853
                        file_put_contents(
854
                            NN_LOGS.'no_files'.$this->groupMySQL['name'].'.log',
0 ignored issues
show
Bug introduced by
The constant Blacklight\NN_LOGS was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
855
                            $this->header['Subject'].PHP_EOL,
856
                            FILE_APPEND
857
                        );
858
                    }
859
                }
860
861
                if ($this->multiGroup) {
862
                    $ckName = '';
863
                    $ckId = '';
864
                } else {
865
                    $ckName = $this->groupMySQL['name'];
866
                    $ckId = $this->groupMySQL['id'];
867
                }
868
869
                $collMatch = $this->_collectionsCleaning->collectionsCleaner(
870
                    $this->header['matches'][1],
871
                    $ckName
872
                );
873
874
                // Used to group articles together when forming the release.  MGR requires this to be group irrespective
875
                $this->header['CollectionKey'] = $collMatch['name'].$ckId.$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...
876
877
                // If this header's collection key isn't in memory, attempt to insert the collection
878
                if (! isset($collectionIDs[$this->header['CollectionKey']])) {
879
880
                    /* Date from header should be a string this format:
881
                     * 31 Mar 2014 15:36:04 GMT or 6 Oct 1998 04:38:40 -0500
882
                     * Still make sure it's not unix time, convert it to unix time if it is.
883
                     */
884
                    $this->header['Date'] = (is_numeric($this->header['Date']) ? $this->header['Date'] : strtotime($this->header['Date']));
885
886
                    // Get the current unixtime from PHP.
887
                    $now = Carbon::now()->timestamp;
888
889
                    $xref = ($this->multiGroup === true ? sprintf('xref = CONCAT(xref, "\\n"%s ),', $this->_pdo->escapeString(substr($this->header['Xref'], 2, 255))) : '');
890
                    $date = $this->header['Date'] > $now ? $now : $this->header['Date'];
891
                    $unixtime = is_numeric($this->header['Date']) ? $date : $now;
892
893
                    $random = random_bytes(16);
894
895
                    $collectionID = $this->_pdo->queryInsert(
896
                        sprintf(
897
                            "
898
							INSERT INTO %s (subject, fromname, date, xref, groups_id,
899
								totalfiles, collectionhash, collection_regexes_id, dateadded)
900
							VALUES (%s, %s, FROM_UNIXTIME(%s), %s, %d, %d, '%s', %d, NOW())
901
							ON DUPLICATE KEY UPDATE %s dateadded = NOW(), noise = '%s'",
902
                            $this->tableNames['cname'],
903
                            $this->_pdo->escapeString(substr(utf8_encode($this->header['matches'][1]), 0, 255)),
904
                            $this->_pdo->escapeString(utf8_encode($this->header['From'])),
905
                            $unixtime,
906
                            $this->_pdo->escapeString(substr($this->header['Xref'], 0, 255)),
907
                            $this->groupMySQL['id'],
908
                            $fileCount[3],
909
                            sha1($this->header['CollectionKey']),
910
                            $collMatch['id'],
911
                            $xref,
912
                            sodium_bin2hex($random)
913
                        )
914
                    );
915
916
                    if ($collectionID === false) {
917
                        if ($this->addToPartRepair) {
918
                            $this->headersNotInserted[] = $this->header['Number'];
919
                        }
920
                        $this->_pdo->Rollback();
921
                        $this->_pdo->beginTransaction();
922
                        continue;
923
                    }
924
                    $collectionIDs[$this->header['CollectionKey']] = $collectionID;
925
                } else {
926
                    $collectionID = $collectionIDs[$this->header['CollectionKey']];
927
                }
928
929
                // MGR or Standard, Binary Hash should be unique to the group
930
                $hash = md5($this->header['matches'][1].$this->header['From'].$this->groupMySQL['id']);
931
932
                $binaryID = $this->_pdo->queryInsert(
933
                    sprintf(
934
                        "
935
						INSERT INTO %s (binaryhash, name, collections_id, totalparts, currentparts, filenumber, partsize)
936
						VALUES (UNHEX('%s'), %s, %d, %d, 1, %d, %d)
937
						ON DUPLICATE KEY UPDATE currentparts = currentparts + 1, partsize = partsize + %d",
938
                        $this->tableNames['bname'],
939
                        $hash,
940
                        $this->_pdo->escapeString(utf8_encode($this->header['matches'][1])),
941
                        $collectionID,
942
                        $this->header['matches'][3],
943
                        $fileCount[1],
944
                        $this->header['Bytes'],
945
                        $this->header['Bytes']
946
                    )
947
                );
948
949
                if ($binaryID === false) {
950
                    if ($this->addToPartRepair) {
951
                        $this->headersNotInserted[] = $this->header['Number'];
952
                    }
953
                    $this->_pdo->Rollback();
954
                    $this->_pdo->beginTransaction();
955
                    continue;
956
                }
957
958
                $binariesUpdate[$binaryID]['Size'] = 0;
959
                $binariesUpdate[$binaryID]['Parts'] = 0;
960
961
                $articles[$this->header['matches'][1]]['CollectionID'] = $collectionID;
962
                $articles[$this->header['matches'][1]]['BinaryID'] = $binaryID;
963
            } else {
964
                $binaryID = $articles[$this->header['matches'][1]]['BinaryID'];
965
                $binariesUpdate[$binaryID]['Size'] += $this->header['Bytes'];
966
                $binariesUpdate[$binaryID]['Parts']++;
967
            }
968
969
            // Strip the < and >, saves space in DB.
970
            $this->header['Message-ID'][0] = "'";
971
972
            $partsQuery .=
973
                '('.$binaryID.','.$this->header['Number'].','.rtrim($this->header['Message-ID'], '>')."',".
974
                $this->header['matches'][2].','.$this->header['Bytes'].'),';
975
        }
976
977
        unset($headers); // Reclaim memory.
978
979
        // Start of inserting into SQL.
980
        $this->startUpdate = microtime(true);
981
982
        // End of processing headers.
983
        $this->timeCleaning = number_format($this->startUpdate - $this->startCleaning, 2);
984
        $binariesQuery = $binariesCheck = sprintf('INSERT INTO %s (id, partsize, currentparts) VALUES ', $this->tableNames['bname']);
985
        foreach ($binariesUpdate as $binaryID => $binary) {
986
            $binariesQuery .= '('.$binaryID.','.$binary['Size'].','.$binary['Parts'].'),';
987
        }
988
        $binariesEnd = ' ON DUPLICATE KEY UPDATE partsize = VALUES(partsize) + partsize, currentparts = VALUES(currentparts) + currentparts';
989
        $binariesQuery = rtrim($binariesQuery, ',').$binariesEnd;
990
991
        // Check if we got any binaries. If we did, try to insert them.
992
        if (\strlen($binariesCheck.$binariesEnd) === \strlen($binariesQuery) ? true : $this->_pdo->queryExec($binariesQuery)) {
993
            if ($this->_debug) {
994
                ColorCLI::doEcho(
995
                    ColorCLI::debug(
996
                        'Sending '.round(\strlen($partsQuery) / 1024, 2).
997
                        ' KB of'.($this->multiGroup ? ' MGR' : '').' parts to MySQL'
998
                    ), true
999
                );
1000
            }
1001
            if (\strlen($partsQuery) === \strlen($partsCheck) ? true : $this->_pdo->queryExec(rtrim($partsQuery, ','))) {
1002
                $this->_pdo->Commit();
1003
            } else {
1004
                if ($this->addToPartRepair) {
1005
                    $this->headersNotInserted += $this->headersReceived;
1006
                }
1007
                $this->_pdo->Rollback();
1008
            }
1009
        } else {
1010
            if ($this->addToPartRepair) {
1011
                $this->headersNotInserted += $this->headersReceived;
1012
            }
1013
            $this->_pdo->Rollback();
1014
        }
1015
    }
1016
1017
    /**
1018
     * Gets the First and Last Article Number and Date for the received headers.
1019
     *
1020
     * @param array $returnArray
1021
     * @param array $headers
1022
     * @param int   $msgCount
1023
     */
1024
    protected function getHighLowArticleInfo(array &$returnArray, array $headers, int $msgCount): void
1025
    {
1026
        // Get highest and lowest article numbers/dates.
1027
        $iterator1 = 0;
1028
        $iterator2 = $msgCount - 1;
1029
        while (true) {
1030
            if (! isset($returnArray['firstArticleNumber']) && isset($headers[$iterator1]['Number'])) {
1031
                $returnArray['firstArticleNumber'] = $headers[$iterator1]['Number'];
1032
                $returnArray['firstArticleDate'] = $headers[$iterator1]['Date'];
1033
            }
1034
1035
            if (! isset($returnArray['lastArticleNumber']) && isset($headers[$iterator2]['Number'])) {
1036
                $returnArray['lastArticleNumber'] = $headers[$iterator2]['Number'];
1037
                $returnArray['lastArticleDate'] = $headers[$iterator2]['Date'];
1038
            }
1039
1040
            // Break if we found non empty articles.
1041
            if (isset($returnArray['firstArticleNumber, lastArticleNumber'])) {
1042
                break;
1043
            }
1044
1045
            // Break out if we couldn't find anything.
1046
            if ($iterator1++ >= $msgCount - 1 || $iterator2-- <= 0) {
1047
                break;
1048
            }
1049
        }
1050
    }
1051
1052
    /**
1053
     * Updates Blacklist Regex Timers in DB to reflect last usage.
1054
     */
1055
    protected function updateBlacklistUsage(): void
1056
    {
1057
        BinaryBlacklist::query()->whereIn('id', $this->_binaryBlacklistIdsToUpdate)->update(['last_activity' => Carbon::now()]);
1058
        $this->_binaryBlacklistIdsToUpdate = [];
1059
    }
1060
1061
    /**
1062
     * Outputs the initial header scan results after yEnc check and blacklist routines.
1063
     */
1064
    protected function outputHeaderInitial(): void
1065
    {
1066
        ColorCLI::doEcho(
1067
            ColorCLI::primary(
1068
                'Received '.\count($this->headersReceived).
1069
                ' articles of '.number_format($this->last - $this->first + 1).' requested, '.
1070
                $this->headersBlackListed.' blacklisted, '.$this->notYEnc.' not yEnc.'
1071
            ), true
1072
        );
1073
    }
1074
1075
    /**
1076
     * Outputs speed metrics of the scan function to CLI.
1077
     */
1078
    protected function outputHeaderDuration(): void
1079
    {
1080
        $currentMicroTime = microtime(true);
1081
        if ($this->_echoCLI) {
1082
            ColorCLI::doEcho(
1083
                ColorCLI::alternateOver($this->timeHeaders.'s').
1084
                ColorCLI::primaryOver(' to download articles, ').
1085
                ColorCLI::alternateOver($this->timeCleaning.'s').
1086
                ColorCLI::primaryOver(' to process collections, ').
1087
                ColorCLI::alternateOver($this->timeInsert.'s').
1088
                ColorCLI::primaryOver(' to insert binaries/parts, ').
1089
                ColorCLI::alternateOver(number_format($currentMicroTime - $this->startPR, 2).'s').
1090
                ColorCLI::primaryOver(' for part repair, ').
1091
                ColorCLI::alternateOver(number_format($currentMicroTime - $this->startLoop, 2).'s').
1092
                ColorCLI::primary(' total.'), true);
1093
        }
1094
    }
1095
1096
    /**
1097
     * If we failed to insert Collections/Binaries/Parts, rollback the transaction and add the parts to part repair.
1098
     *
1099
     * @param array $headers Array of headers containing sub-arrays with parts.
1100
     *
1101
     * @return array Array of article numbers to add to part repair.
1102
     */
1103
    protected function _rollbackAddToPartRepair(array $headers): array
1104
    {
1105
        $headersNotInserted = [];
1106
        foreach ($headers as $header) {
1107
            foreach ($header as $file) {
1108
                $headersNotInserted[] = $file['Parts']['number'];
1109
            }
1110
        }
1111
        $this->_pdo->Rollback();
1112
1113
        return $headersNotInserted;
1114
    }
1115
1116
    /**
1117
     * Attempt to get missing article headers.
1118
     *
1119
     * @param array|string $tables
1120
     * @param array        $groupArr The info for this group from mysql.
1121
     *
1122
     * @return void
1123
     * @throws \Exception
1124
     */
1125
    public function partRepair($groupArr, $tables = ''): void
1126
    {
1127
        $tableNames = $tables;
1128
1129
        if ($tableNames === '') {
1130
            $tableNames = (new Group())->getCBPTableNames($groupArr['id']);
1131
        }
1132
        // Get all parts in partrepair table.
1133
        $missingParts = $this->_pdo->query(
1134
            sprintf(
1135
                '
1136
				SELECT * FROM %s
1137
				WHERE groups_id = %d AND attempts < %d
1138
				ORDER BY numberid ASC LIMIT %d',
1139
                $tableNames['prname'],
1140
                $groupArr['id'],
1141
                $this->_partRepairMaxTries,
1142
                $this->_partRepairLimit
1143
            )
1144
        );
1145
1146
        $missingCount = \count($missingParts);
1147
        if ($missingCount > 0) {
1148
            if ($this->_echoCLI) {
1149
                ColorCLI::doEcho(
1150
                    ColorCLI::primary(
1151
                        'Attempting to repair '.
1152
                        number_format($missingCount).
1153
                        ' parts.'
1154
                    ),
1155
                    true
1156
                );
1157
            }
1158
1159
            // Loop through each part to group into continuous ranges with a maximum range of messagebuffer/4.
1160
            $ranges = $partList = [];
1161
            $firstPart = $lastNum = $missingParts[0]['numberid'];
1162
1163
            foreach ($missingParts as $part) {
1164
                if (($part['numberid'] - $firstPart) > ($this->messageBuffer / 4)) {
1165
                    $ranges[] = [
1166
                        'partfrom' => $firstPart,
1167
                        'partto'   => $lastNum,
1168
                        'partlist' => $partList,
1169
                    ];
1170
1171
                    $firstPart = $part['numberid'];
1172
                    $partList = [];
1173
                }
1174
                $partList[] = $part['numberid'];
1175
                $lastNum = $part['numberid'];
1176
            }
1177
1178
            $ranges[] = [
1179
                'partfrom' => $firstPart,
1180
                'partto'   => $lastNum,
1181
                'partlist' => $partList,
1182
            ];
1183
1184
            // Download missing parts in ranges.
1185
            foreach ($ranges as $range) {
1186
                $partFrom = $range['partfrom'];
1187
                $partTo = $range['partto'];
1188
                $partList = $range['partlist'];
1189
1190
                if ($this->_echoCLI) {
1191
                    echo \chr(random_int(45, 46)).PHP_EOL;
1192
                }
1193
1194
                // Get article headers from newsgroup.
1195
                $this->scan($groupArr, $partFrom, $partTo, 'missed_parts', $partList);
1196
            }
1197
1198
            // Calculate parts repaired
1199
            $result = $this->_pdo->queryOneRow(
1200
                sprintf(
1201
                    '
1202
					SELECT COUNT(id) AS num
1203
					FROM %s
1204
					WHERE groups_id = %d
1205
					AND numberid <= %d',
1206
                    $tableNames['prname'],
1207
                    $groupArr['id'],
1208
                    $missingParts[$missingCount - 1]['numberid']
1209
                )
1210
            );
1211
1212
            $partsRepaired = 0;
1213
            if ($result !== false) {
1214
                $partsRepaired = ($missingCount - $result['num']);
1215
            }
1216
1217
            // Update attempts on remaining parts for active group
1218
            if (isset($missingParts[$missingCount - 1]['id'])) {
1219
                $this->_pdo->queryExec(
1220
                    sprintf(
1221
                        '
1222
						UPDATE %s
1223
						SET attempts = attempts + 1
1224
						WHERE groups_id = %d
1225
						AND numberid <= %d',
1226
                        $tableNames['prname'],
1227
                        $groupArr['id'],
1228
                        $missingParts[$missingCount - 1]['numberid']
1229
                    )
1230
                );
1231
            }
1232
1233
            if ($this->_echoCLI) {
1234
                ColorCLI::doEcho(
1235
                    ColorCLI::primary(
1236
                        PHP_EOL.
1237
                        number_format($partsRepaired).
1238
                        ' parts repaired.'
1239
                    ),
1240
                    true
1241
                );
1242
            }
1243
        }
1244
1245
        // Remove articles that we cant fetch after x attempts.
1246
        $this->_pdo->queryExec(
1247
            sprintf(
1248
                'DELETE FROM %s WHERE attempts >= %d AND groups_id = %d',
1249
                $tableNames['prname'],
1250
                $this->_partRepairMaxTries,
1251
                $groupArr['id']
1252
            )
1253
        );
1254
    }
1255
1256
    /**
1257
     * Returns unix time for an article number.
1258
     *
1259
     * @param int   $post      The article number to get the time from.
1260
     * @param array $groupData Usenet group info from NNTP selectGroup method.
1261
     *
1262
     * @return int    Timestamp.
1263
     * @throws \Exception
1264
     */
1265
    public function postdate($post, array $groupData): int
1266
    {
1267
        // Set table names
1268
        $groupID = Group::getIDByName($groupData['group']);
1269
        $group = [];
1270
        if ($groupID !== '') {
1271
            $group = (new Group())->getCBPTableNames($groupID);
0 ignored issues
show
Bug introduced by
It seems like $groupID can also be of type string; however, parameter $groupID of App\Models\Group::getCBPTableNames() does only seem to accept integer, 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

1271
            $group = (new Group())->getCBPTableNames(/** @scrutinizer ignore-type */ $groupID);
Loading history...
1272
        }
1273
1274
        $currentPost = $post;
1275
1276
        $attempts = $date = 0;
1277
        do {
1278
            // Try to get the article date locally first.
1279
            if ($groupID !== '') {
1280
                // Try to get locally.
1281
                $local = $this->_pdo->queryOneRow(
1282
                    sprintf(
1283
                        '
1284
						SELECT c.date AS date
1285
						FROM %s c
1286
						INNER JOIN %s b ON(c.id=b.collections_id)
1287
						INNER JOIN %s p ON(b.id=p.binaries_id)
1288
						WHERE p.number = %s',
1289
                        $group['cname'],
1290
                        $group['bname'],
1291
                        $group['pname'],
1292
                        $currentPost
1293
                    )
1294
                );
1295
                if ($local !== false) {
1296
                    $date = $local['date'];
1297
                    break;
1298
                }
1299
            }
1300
1301
            // If we could not find it locally, try usenet.
1302
            $header = $this->_nntp->getXOVER($currentPost);
1303
            if (! $this->_nntp->isError($header)) {
1304
                // Check if the date is set.
1305
                if (isset($header[0]['Date']) && \strlen($header[0]['Date']) > 0) {
1306
                    $date = $header[0]['Date'];
1307
                    break;
1308
                }
1309
            }
1310
1311
            // Try to get a different article number.
1312
            if (abs($currentPost - $groupData['first']) > abs($groupData['last'] - $currentPost)) {
1313
                $tempPost = round($currentPost / (random_int(1005, 1012) / 1000), 0, PHP_ROUND_HALF_UP);
1314
                if ($tempPost < $groupData['first']) {
1315
                    $tempPost = $groupData['first'];
1316
                }
1317
            } else {
1318
                $tempPost = round((random_int(1005, 1012) / 1000) * $currentPost, 0, PHP_ROUND_HALF_UP);
1319
                if ($tempPost > $groupData['last']) {
1320
                    $tempPost = $groupData['last'];
1321
                }
1322
            }
1323
            // If we got the same article number as last time, give up.
1324
            if ($tempPost === $currentPost) {
1325
                break;
1326
            }
1327
            $currentPost = $tempPost;
1328
1329
            if ($this->_debug) {
1330
                ColorCLI::doEcho(ColorCLI::debug('Postdate retried '.$attempts.' time(s).'), true);
1331
            }
1332
        } while ($attempts++ <= 20);
1333
1334
        // If we didn't get a date, set it to now.
1335
        if (! $date) {
1336
            $date = time();
1337
        } else {
1338
            $date = strtotime($date);
1339
        }
1340
1341
        return $date;
1342
    }
1343
1344
    /**
1345
     * Returns article number based on # of days.
1346
     *
1347
     * @param int   $days How many days back we want to go.
1348
     * @param array $data Group data from usenet.
1349
     *
1350
     * @return string
1351
     * @throws \Exception
1352
     */
1353
    public function daytopost($days, $data): string
1354
    {
1355
        $goalTime = Carbon::now()->subDays($days)->timestamp;
1356
        // The time we want = current unix time (ex. 1395699114) - minus 86400 (seconds in a day)
1357
        // times days wanted. (ie 1395699114 - 2592000 (30days)) = 1393107114
1358
1359
        // The servers oldest date.
1360
        $firstDate = $this->postdate($data['first'], $data);
1361
        if ($goalTime < $firstDate) {
1362
            // If the date we want is older than the oldest date in the group return the groups oldest article.
1363
            return $data['first'];
1364
        }
1365
1366
        // The servers newest date.
1367
        $lastDate = $this->postdate($data['last'], $data);
1368
        if ($goalTime > $lastDate) {
1369
            // If the date we want is newer than the groups newest date, return the groups newest article.
1370
            return $data['last'];
1371
        }
1372
1373
        if ($this->_echoCLI) {
1374
            ColorCLI::doEcho(
1375
                ColorCLI::primary(
1376
                    'Searching for an approximate article number for group '.$data['group'].' '.$days.' days back.'
1377
                ), true
1378
            );
1379
        }
1380
1381
        // Pick the middle to start with
1382
        $wantedArticle = round(($data['last'] + $data['first']) / 2);
1383
        $aMax = $data['last'];
1384
        $aMin = $data['first'];
1385
        $reallyOldArticle = $oldArticle = $articleTime = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $reallyOldArticle is dead and can be removed.
Loading history...
1386
1387
        while (true) {
1388
            // Article exists outside of available range, this shouldn't happen
1389
            if ($wantedArticle <= $data['first'] || $wantedArticle >= $data['last']) {
1390
                break;
1391
            }
1392
1393
            // Keep a note of the last articles we checked
1394
            $reallyOldArticle = $oldArticle;
1395
            $oldArticle = $wantedArticle;
1396
1397
            // Get the date of this article
1398
            $articleTime = $this->postdate($wantedArticle, $data);
0 ignored issues
show
Bug introduced by
It seems like $wantedArticle can also be of type double; however, parameter $post of Blacklight\Binaries::postdate() does only seem to accept integer, 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

1398
            $articleTime = $this->postdate(/** @scrutinizer ignore-type */ $wantedArticle, $data);
Loading history...
1399
1400
            // Article doesn't exist, start again with something random
1401
            if (! $articleTime) {
1402
                $wantedArticle = random_int($aMin, $aMax);
1403
                $articleTime = $this->postdate($wantedArticle, $data);
1404
            }
1405
1406
            if ($articleTime < $goalTime) {
1407
                // Article is older than we want
1408
                $aMin = $oldArticle;
1409
                $wantedArticle = round(($aMax + $oldArticle) / 2);
1410
                if ($this->_echoCLI) {
1411
                    echo '-';
1412
                }
1413
            } elseif ($articleTime > $goalTime) {
1414
                // Article is newer than we want
1415
                $aMax = $oldArticle;
1416
                $wantedArticle = round(($aMin + $oldArticle) / 2);
1417
                if ($this->_echoCLI) {
1418
                    echo '+';
1419
                }
1420
            } elseif ($articleTime === $goalTime) {
1421
                // Exact match. We did it! (this will likely never happen though)
1422
                break;
1423
            }
1424
1425
            // We seem to be flip-flopping between 2 articles, assume we're out of articles to check.
1426
            // End on an article more recent than our oldest so that we don't miss any releases.
1427
            if ($reallyOldArticle === $wantedArticle && ($goalTime - $articleTime) <= 0) {
1428
                break;
1429
            }
1430
        }
1431
1432
        $wantedArticle = (int) $wantedArticle;
1433
        if ($this->_echoCLI) {
1434
            ColorCLI::doEcho(
1435
                ColorCLI::primary(
1436
                    PHP_EOL.'Found article #'.$wantedArticle.' which has a date of '.date('r', $articleTime).
1437
                    ', vs wanted date of '.date('r', $goalTime).'. Difference from goal is '.Carbon::createFromTimestamp($goalTime)->diffInDays(Carbon::createFromTimestamp($articleTime)).'days.'
1438
                ), true
1439
            );
1440
        }
1441
1442
        return $wantedArticle;
1443
    }
1444
1445
    /**
1446
     * Convert unix time to days ago.
1447
     *
1448
     *
1449
     * @param $timestamp
1450
     * @return int
1451
     */
1452
    private function daysOld($timestamp): int
1453
    {
1454
        return Carbon::createFromTimestamp($timestamp)->diffInDays();
1455
    }
1456
1457
    /**
1458
     * Add article numbers from missing headers to DB.
1459
     *
1460
     * @param array  $numbers   The article numbers of the missing headers.
1461
     * @param string $tableName Name of the partrepair table to insert into.
1462
     * @param int    $groupID   The ID of this groups.
1463
     *
1464
     * @return bool
1465
     */
1466
    private function addMissingParts($numbers, $tableName, $groupID): bool
1467
    {
1468
        $insertStr = 'INSERT INTO '.$tableName.' (numberid, groups_id) VALUES ';
1469
        foreach ($numbers as $number) {
1470
            $insertStr .= '('.$number.','.$groupID.'),';
1471
        }
1472
1473
        return $this->_pdo->queryInsert(rtrim($insertStr, ',').' ON DUPLICATE KEY UPDATE attempts=attempts+1');
1474
    }
1475
1476
    /**
1477
     * Clean up part repair table.
1478
     *
1479
     * @param array  $numbers   The article numbers.
1480
     * @param string $tableName Name of the part repair table to work on.
1481
     * @param int    $groupID   The ID of the group.
1482
     *
1483
     * @return void
1484
     */
1485
    private function removeRepairedParts(array $numbers, $tableName, $groupID): void
1486
    {
1487
        $sql = 'DELETE FROM '.$tableName.' WHERE numberid in (';
1488
        foreach ($numbers as $number) {
1489
            $sql .= $number.',';
1490
        }
1491
        $this->_pdo->queryExec(rtrim($sql, ',').') AND groups_id = '.$groupID);
1492
    }
1493
1494
    /**
1495
     * Are white or black lists loaded for a group name?
1496
     * @var array
1497
     */
1498
    protected $_listsFound = [];
1499
1500
    /**
1501
     * Get blacklist and cache it. Return if already cached.
1502
     *
1503
     * @param string $groupName
1504
     *
1505
     * @return void
1506
     */
1507
    protected function _retrieveBlackList($groupName): void
1508
    {
1509
        if (! isset($this->blackList[$groupName])) {
1510
            $this->blackList[$groupName] = $this->getBlacklist(true, self::OPTYPE_BLACKLIST, $groupName, true);
1511
        }
1512
        if (! isset($this->whiteList[$groupName])) {
1513
            $this->whiteList[$groupName] = $this->getBlacklist(true, self::OPTYPE_WHITELIST, $groupName, true);
1514
        }
1515
        $this->_listsFound[$groupName] = ($this->blackList[$groupName] || $this->whiteList[$groupName]);
1516
    }
1517
1518
    /**
1519
     * Check if an article is blacklisted.
1520
     *
1521
     * @param array  $msg       The article header (OVER format).
1522
     * @param string $groupName The group name.
1523
     *
1524
     * @return bool
1525
     */
1526
    public function isBlackListed($msg, $groupName): bool
1527
    {
1528
        if (! isset($this->_listsFound[$groupName])) {
1529
            $this->_retrieveBlackList($groupName);
1530
        }
1531
        if (! $this->_listsFound[$groupName]) {
1532
            return false;
1533
        }
1534
1535
        $blackListed = false;
1536
1537
        $field = [
1538
            self::BLACKLIST_FIELD_SUBJECT   => $msg['Subject'],
1539
            self::BLACKLIST_FIELD_FROM      => $msg['From'],
1540
            self::BLACKLIST_FIELD_MESSAGEID => $msg['Message-ID'],
1541
        ];
1542
1543
        // Try white lists first.
1544
        if ($this->whiteList[$groupName]) {
1545
            // There are white lists for this group, so anything that doesn't match a white list should be considered black listed.
1546
            $blackListed = true;
1547
            foreach ($this->whiteList[$groupName] as $whiteList) {
1548
                if (preg_match('/'.$whiteList['regex'].'/i', $field[$whiteList['msgcol']])) {
1549
                    // This field matched a white list, so it might not be black listed.
1550
                    $blackListed = false;
1551
                    $this->_binaryBlacklistIdsToUpdate[$whiteList['id']] = $whiteList['id'];
1552
                    break;
1553
                }
1554
            }
1555
        }
1556
1557
        // Check if the field is black listed.
1558
        if (! $blackListed && $this->blackList[$groupName]) {
1559
            foreach ($this->blackList[$groupName] as $blackList) {
1560
                if (preg_match('/'.$blackList['regex'].'/i', $field[$blackList['msgcol']])) {
1561
                    $blackListed = true;
1562
                    $this->_binaryBlacklistIdsToUpdate[$blackList['id']] = $blackList['id'];
1563
                    break;
1564
                }
1565
            }
1566
        }
1567
1568
        return $blackListed;
1569
    }
1570
1571
    /**
1572
     * Return all blacklists.
1573
     *
1574
     * @param bool   $activeOnly Only display active blacklists ?
1575
     * @param int|string    $opType     Optional, get white or black lists (use Binaries constants).
1576
     * @param string $groupName  Optional, group.
1577
     * @param bool   $groupRegex Optional Join groups / binaryblacklist using regexp for equals.
1578
     *
1579
     * @return array
1580
     */
1581
    public function getBlacklist($activeOnly = true, $opType = -1, $groupName = '', $groupRegex = false): array
1582
    {
1583
        switch ($opType) {
1584
            case self::OPTYPE_BLACKLIST:
1585
                $opType = 'AND bb.optype = '.self::OPTYPE_BLACKLIST;
1586
                break;
1587
            case self::OPTYPE_WHITELIST:
1588
                $opType = 'AND bb.optype = '.self::OPTYPE_WHITELIST;
1589
                break;
1590
            default:
1591
                $opType = '';
1592
                break;
1593
        }
1594
1595
        return $this->_pdo->query(
1596
            sprintf(
1597
                '
1598
				SELECT
1599
					bb.id, bb.optype, bb.status, bb.description,
1600
					bb.groupname AS groupname, bb.regex, g.id AS group_id, bb.msgcol,
1601
					bb.last_activity as last_activity
1602
				FROM binaryblacklist bb
1603
				LEFT OUTER JOIN groups g ON g.name %s bb.groupname
1604
				WHERE 1=1 %s %s %s
1605
				ORDER BY coalesce(groupname,\'zzz\')',
1606
                ($groupRegex ? 'REGEXP' : '='),
1607
                ($activeOnly ? 'AND bb.status = 1' : ''),
1608
                $opType,
1609
                ($groupName ? ('AND g.name REGEXP '.$this->_pdo->escapeString($groupName)) : '')
1610
            )
1611
        );
1612
    }
1613
1614
    /**
1615
     * Return the specified blacklist.
1616
     *
1617
     * @param int $id The blacklist ID.
1618
     *
1619
     * @return \Illuminate\Database\Eloquent\Model|null|static
1620
     */
1621
    public function getBlacklistByID($id)
1622
    {
1623
        return BinaryBlacklist::query()->where('id', $id)->first();
1624
    }
1625
1626
    /**
1627
     * Delete a blacklist.
1628
     *
1629
     * @param int $id The ID of the blacklist.
1630
     */
1631
    public function deleteBlacklist($id): void
1632
    {
1633
        BinaryBlacklist::query()->where('id', $id)->delete();
1634
    }
1635
1636
    /**
1637
     * @param $blacklistArray
1638
     */
1639
    public function updateBlacklist($blacklistArray): void
1640
    {
1641
        BinaryBlacklist::query()->where('id', $blacklistArray['id'])->update(
1642
            [
1643
                'groupname' => $blacklistArray['groupname'] === '' ? 'null' : preg_replace('/a\.b\./i', 'alt.binaries.', $blacklistArray['groupname']),
1644
                'regex' => $blacklistArray['regex'],
1645
                'status' => $blacklistArray['status'],
1646
                'description' => $blacklistArray['description'],
1647
                'optype' => $blacklistArray['optype'],
1648
                'msgcol' => $blacklistArray['msgcol'],
1649
            ]
1650
        );
1651
    }
1652
1653
    /**
1654
     * Adds a new blacklist from binary blacklist edit admin web page.
1655
     *
1656
     * @param array $blacklistArray
1657
     */
1658
    public function addBlacklist($blacklistArray): void
1659
    {
1660
        BinaryBlacklist::query()->insert(
1661
            [
1662
                'groupname' => $blacklistArray['groupname'] === '' ? 'null' : preg_replace('/a\.b\./i', 'alt.binaries.', $blacklistArray['groupname']),
1663
                'regex' => $blacklistArray['regex'],
1664
                'status' => $blacklistArray['status'],
1665
                'description' => $blacklistArray['description'],
1666
                'optype' => $blacklistArray['optype'],
1667
                'msgcol' => $blacklistArray['msgcol'],
1668
            ]
1669
        );
1670
    }
1671
1672
    /**
1673
     * Delete Collections/Binaries/Parts for a Collection ID.
1674
     *
1675
     * @param int $collectionID Collections table ID
1676
     *
1677
     * @note A trigger automatically deletes the parts/binaries.
1678
     *
1679
     * @return void
1680
     */
1681
    public function delete($collectionID): void
1682
    {
1683
        $this->_pdo->queryExec(sprintf('DELETE FROM collections WHERE id = %d', $collectionID));
1684
    }
1685
1686
    /**
1687
     * Log / Echo message.
1688
     *
1689
     * @param string $message Message to log.
1690
     * @param string $method  Method that called this.
1691
     * @param string $color   ColorCLI method name.
1692
     */
1693
    private function log($message, $method, $color): void
1694
    {
1695
        if ($this->_echoCLI) {
1696
            ColorCLI::doEcho(
1697
                ColorCLI::$color($message.' ['.__CLASS__."::$method]"),
1698
                true
1699
            );
1700
        }
1701
    }
1702
1703
    /**
1704
     * Check if we should ignore the file count and return true or false.
1705
     *
1706
     * @param string $groupName
1707
     * @param string $subject
1708
     *
1709
     * @return bool
1710
     */
1711
    protected function _ignoreFileCount($groupName, $subject): bool
1712
    {
1713
        $ignore = false;
1714
        switch ($groupName) {
1715
            case 'alt.binaries.erotica':
1716
                if (preg_match('/^\[\d+\]-\[FULL\]-\[#a\.b\.erotica@EFNet\]-\[ \d{2,3}_/', $subject)) {
1717
                    $ignore = true;
1718
                }
1719
                break;
1720
        }
1721
1722
        return $ignore;
1723
    }
1724
1725
    /**
1726
     * Returns all multigroup poster entries from the database.
1727
     *
1728
     *
1729
     * @return \Illuminate\Database\Eloquent\Collection|static[]
1730
     */
1731
    protected function getMultiGroupPosters()
1732
    {
1733
        $poster = Cache::get('mgrposter');
1734
        if ($poster !== null) {
1735
            return $poster;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $poster returns the type Illuminate\Contracts\Cache\Repository which is incompatible with the documented return type Illuminate\Database\Eloq...ed,Blacklight\Binaries>.
Loading history...
1736
        }
1737
1738
        $poster = MultigroupPoster::query()->get(['poster'])->toArray();
1739
        $expiresAt = Carbon::now()->addSeconds(config('nntmux.cache_expiry_short'));
1740
        Cache::put('mgrposter', $poster, $expiresAt);
1741
1742
        return $poster;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $poster returns the type App\Models\MultigroupPoster[] which is incompatible with the documented return type Illuminate\Database\Eloq...ed,Blacklight\Binaries>.
Loading history...
1743
    }
1744
}
1745