ArchiveExtractionService   F
last analyzed

Complexity

Total Complexity 138

Size/Duplication

Total Lines 707
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 138
eloc 371
dl 0
loc 707
rs 2
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
B scanSevenZipFilenames() 0 31 8
B extractArchive() 0 25 7
A getAllowedExtensions() 0 4 1
B prepareExtractionDirectories() 0 20 7
D processCompressedData() 0 101 19
C detectStandaloneVideo() 0 42 15
B filterSevenZipFiles() 0 41 8
B detectArchiveType() 0 27 7
B listSevenZipEntries() 0 32 8
B isLikely7z() 0 15 7
A getPar2Info() 0 3 1
A filterExtractedFiles() 0 23 4
C listSevenZipPlain() 0 39 14
C parseSevenZipStructuredOutput() 0 43 12
B extractViaSevenZip() 0 65 9
A execCommand() 0 20 2
A getArchiveInfo() 0 3 1
B processSevenZipArchive() 0 62 6
A __construct() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like ArchiveExtractionService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ArchiveExtractionService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace App\Services\AdditionalProcessing;
4
5
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
0 ignored issues
show
Bug introduced by
The type App\Services\AdditionalP...ProcessingConfiguration was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
6
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
7
use Blacklight\Releases;
8
use dariusiii\rarinfo\ArchiveInfo;
9
use dariusiii\rarinfo\Par2Info;
10
use Illuminate\Support\Facades\File;
11
use Illuminate\Support\Facades\Log;
12
13
/**
14
 * Service for extracting and processing archive files (RAR, ZIP, 7z, gzip, bzip2, xz).
15
 * Handles password detection, file listing, and content extraction.
16
 */
17
class ArchiveExtractionService
18
{
19
    private ArchiveInfo $archiveInfo;
20
    private Par2Info $par2Info;
21
22
    public function __construct(
23
        private readonly ProcessingConfiguration $config
24
    ) {
25
        $this->archiveInfo = new ArchiveInfo();
26
        $this->par2Info = new Par2Info();
27
28
        // Configure external clients for ArchiveInfo
29
        if ($this->config->unrarPath) {
30
            $this->archiveInfo->setExternalClients([ArchiveInfo::TYPE_RAR => $this->config->unrarPath]);
31
        }
32
    }
33
34
    /**
35
     * Process compressed data and extract file information.
36
     *
37
     * @return array{success: bool, files: array, hasPassword: bool, passwordStatus: int}
38
     */
39
    public function processCompressedData(
40
        string $compressedData,
41
        ReleaseProcessingContext $context,
42
        string $tmpPath
43
    ): array {
44
        $result = [
45
            'success' => false,
46
            'files' => [],
47
            'hasPassword' => false,
48
            'passwordStatus' => Releases::PASSWD_NONE,
49
        ];
50
51
        $context->compressedFilesChecked++;
52
53
        // Detect archive type early
54
        $archiveType = $this->detectArchiveType($compressedData);
55
56
        // Handle 7z, gzip, bzip2, xz with external 7zip binary
57
        if (in_array($archiveType, ['7z', 'gzip', 'bzip2', 'xz'], true)) {
58
            if ($archiveType === '7z') {
59
                $sevenZipResult = $this->processSevenZipArchive($compressedData, $context, $tmpPath);
60
                if ($sevenZipResult['success'] || $sevenZipResult['hasPassword']) {
61
                    return $sevenZipResult;
62
                }
63
            }
64
65
            if ($this->config->sevenZipPath) {
66
                $extractResult = $this->extractViaSevenZip($compressedData, $archiveType, $tmpPath);
0 ignored issues
show
Bug introduced by
It seems like $archiveType can also be of type null; however, parameter $type of App\Services\AdditionalP...e::extractViaSevenZip() does only seem to accept string, 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

66
                $extractResult = $this->extractViaSevenZip($compressedData, /** @scrutinizer ignore-type */ $archiveType, $tmpPath);
Loading history...
67
                if ($extractResult['success']) {
68
                    return $extractResult;
69
                }
70
            }
71
        }
72
73
        // Try ArchiveInfo for RAR/ZIP
74
        if (! $this->archiveInfo->setData($compressedData, true)) {
75
            // Handle standalone video detection
76
            $videoType = $this->detectStandaloneVideo($compressedData);
77
            if ($videoType !== null) {
78
                return [
79
                    'success' => false,
80
                    'files' => [],
81
                    'hasPassword' => false,
82
                    'passwordStatus' => Releases::PASSWD_NONE,
83
                    'standaloneVideoType' => $videoType,
84
                    'standaloneVideoData' => $compressedData,
85
                ];
86
            }
87
            return $result;
88
        }
89
90
        if ($this->archiveInfo->error !== '') {
91
            if ($this->config->debugMode) {
92
                Log::debug('ArchiveInfo Error: '.$this->archiveInfo->error);
93
            }
94
            return $result;
95
        }
96
97
        try {
98
            $dataSummary = $this->archiveInfo->getSummary(true);
99
        } catch (\Exception $e) {
100
            if ($this->config->debugMode) {
101
                Log::warning($e->getTraceAsString());
102
            }
103
            return $result;
104
        }
105
106
        // Check for encryption
107
        if (! empty($this->archiveInfo->isEncrypted)
108
            || (isset($dataSummary['is_encrypted']) && (int) $dataSummary['is_encrypted'] !== 0)
109
        ) {
110
            if ($this->config->debugMode) {
111
                Log::debug('ArchiveInfo: Compressed file has a password.');
112
            }
113
            return [
114
                'success' => false,
115
                'files' => [],
116
                'hasPassword' => true,
117
                'passwordStatus' => Releases::PASSWD_RAR,
118
            ];
119
        }
120
121
        // Prepare extraction directories
122
        $this->prepareExtractionDirectories($tmpPath);
123
124
        // Process based on archive type
125
        $archiveMarker = $this->extractArchive($compressedData, $dataSummary, $tmpPath);
126
127
        // Get file list
128
        $files = $this->archiveInfo->getArchiveFileList();
129
        if (! is_array($files) || count($files) === 0) {
130
            return $result;
131
        }
132
133
        return [
134
            'success' => true,
135
            'files' => $files,
136
            'hasPassword' => false,
137
            'passwordStatus' => Releases::PASSWD_NONE,
138
            'archiveMarker' => $archiveMarker,
139
            'dataSummary' => $dataSummary,
140
        ];
141
    }
142
143
    /**
144
     * Detect the archive type from binary signature.
145
     */
146
    public function detectArchiveType(string $data): ?string
147
    {
148
        $head6 = substr($data, 0, 6);
149
        $head4 = substr($data, 0, 4);
150
151
        // 7z signature
152
        if ($head6 === "\x37\x7A\xBC\xAF\x27\x1C" && $this->isLikely7z($data)) {
153
            return '7z';
154
        }
155
        // GZIP
156
        if (strncmp($head4, "\x1F\x8B\x08", 3) === 0) {
157
            return 'gzip';
158
        }
159
        // BZip2
160
        if (strncmp($head4, 'BZh', 3) === 0) {
161
            return 'bzip2';
162
        }
163
        // XZ
164
        if ($head6 === "\xFD7zXZ\x00") {
165
            return 'xz';
166
        }
167
        // PDF (skip)
168
        if ($head4 === '%PDF') {
169
            return 'pdf';
170
        }
171
172
        return null;
173
    }
174
175
    /**
176
     * Heuristic validation for 7z signature.
177
     */
178
    private function isLikely7z(string $data): bool
179
    {
180
        if (strlen($data) < 32) {
181
            return false;
182
        }
183
        $verMajor = ord($data[6]);
184
        $verMinor = ord($data[7]);
185
        if ($verMajor !== 0x00 || $verMinor < 0x02 || $verMinor > 0x09) {
186
            return false;
187
        }
188
        $crc = substr($data, 8, 4);
189
        if ($crc === "\x00\x00\x00\x00" || $crc === "\xFF\xFF\xFF\xFF") {
190
            return false;
191
        }
192
        return true;
193
    }
194
195
    /**
196
     * Process a 7z archive using external binary and internal header parsing.
197
     */
198
    private function processSevenZipArchive(
199
        string $compressedData,
200
        ReleaseProcessingContext $context,
0 ignored issues
show
Unused Code introduced by
The parameter $context is not used and could be removed. ( Ignorable by Annotation )

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

200
        /** @scrutinizer ignore-unused */ ReleaseProcessingContext $context,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
201
        string $tmpPath
202
    ): array {
203
        $result = [
204
            'success' => false,
205
            'files' => [],
206
            'hasPassword' => false,
207
            'passwordStatus' => Releases::PASSWD_NONE,
208
        ];
209
210
        if (! $this->config->sevenZipPath) {
211
            return $result;
212
        }
213
214
        // Try listing with external 7z binary
215
        $listed = $this->listSevenZipEntries($compressedData, $tmpPath);
216
        if (! empty($listed)) {
217
            if (! empty($listed[0]['__any_encrypted__'])) {
218
                return [
219
                    'success' => false,
220
                    'files' => [],
221
                    'hasPassword' => true,
222
                    'passwordStatus' => Releases::PASSWD_RAR,
223
                ];
224
            }
225
226
            $files = $this->filterSevenZipFiles($listed);
227
            if (! empty($files)) {
228
                return [
229
                    'success' => true,
230
                    'files' => $files,
231
                    'hasPassword' => false,
232
                    'passwordStatus' => Releases::PASSWD_NONE,
233
                    'archiveMarker' => '7z',
234
                ];
235
            }
236
        }
237
238
        // Fallback: scan for filenames in raw data
239
        $scannedNames = $this->scanSevenZipFilenames($compressedData);
240
        if (! empty($scannedNames)) {
241
            $files = array_map(fn($name) => [
242
                'name' => $name,
243
                'size' => 0,
244
                'date' => time(),
245
                'pass' => 0,
246
                'crc32' => '',
247
                'source' => '7z-scan',
248
            ], $scannedNames);
249
250
            return [
251
                'success' => true,
252
                'files' => $files,
253
                'hasPassword' => false,
254
                'passwordStatus' => Releases::PASSWD_NONE,
255
                'archiveMarker' => '7z',
256
            ];
257
        }
258
259
        return $result;
260
    }
261
262
    /**
263
     * List entries of a 7z archive using external 7z binary.
264
     */
265
    public function listSevenZipEntries(string $compressedData, string $tmpPath): array
266
    {
267
        if (! $this->config->sevenZipPath) {
268
            return [];
269
        }
270
271
        try {
272
            $tmpFile = $tmpPath.uniqid('7zlist_', true).'.7z';
273
            if (File::put($tmpFile, $compressedData) === false) {
274
                return [];
275
            }
276
277
            $cmd = [$this->config->sevenZipPath, 'l', '-slt', '-ba', '-bd', $tmpFile];
278
            $exitCode = 0;
279
            $stdout = null;
280
            $stderr = null;
281
            $ok = $this->execCommand($cmd, $exitCode, $stdout, $stderr);
282
283
            if (! $ok || $exitCode !== 0 || empty($stdout)) {
284
                // Try plain listing fallback
285
                $plainResult = $this->listSevenZipPlain($tmpFile);
286
                File::delete($tmpFile);
287
                return $plainResult;
288
            }
289
290
            File::delete($tmpFile);
291
            return $this->parseSevenZipStructuredOutput($stdout);
292
        } catch (\Throwable $e) {
293
            if ($this->config->debugMode) {
294
                Log::debug('Exception listing 7z: '.$e->getMessage());
295
            }
296
            return [];
297
        }
298
    }
299
300
    /**
301
     * Plain 7z listing fallback.
302
     */
303
    private function listSevenZipPlain(string $tmpFile): array
304
    {
305
        $cmd = [$this->config->sevenZipPath, 'l', '-ba', '-bd', $tmpFile];
306
        $exitCode = 0;
307
        $stdout = null;
308
        $stderr = null;
309
        $ok = $this->execCommand($cmd, $exitCode, $stdout, $stderr);
310
311
        if (! $ok || $exitCode !== 0 || empty($stdout)) {
312
            return [];
313
        }
314
315
        $files = [];
316
        $lines = preg_split('/\r?\n/', trim($stdout));
317
        foreach ($lines as $line) {
318
            $line = trim($line);
319
            if ($line === '' || str_starts_with($line, '-----')
320
                || str_contains($line, '   Date   ')
321
                || str_starts_with($line, 'Scanning ')
322
            ) {
323
                continue;
324
            }
325
326
            $name = null;
327
            if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+\S+\s+\d+\s+\d+\s+(\S.*)$/', $line, $m)) {
328
                $name = $m[1];
329
            } elseif (preg_match('/([A-Za-z0-9_#@()\[\]\-+&., ]+\.[A-Za-z0-9]{2,8})$/', $line, $m2)) {
330
                $name = trim($m2[1]);
331
            }
332
333
            if ($name && strlen($name) <= 300) {
334
                $files[] = ['name' => trim($name), 'size' => 0, 'encrypted' => false];
335
                if (count($files) >= 200) {
336
                    break;
337
                }
338
            }
339
        }
340
341
        return $files;
342
    }
343
344
    /**
345
     * Parse structured 7z output.
346
     */
347
    private function parseSevenZipStructuredOutput(string $output): array
348
    {
349
        $blocks = preg_split('/\n\n+/u', trim($output));
350
        $files = [];
351
        $anyEncrypted = false;
352
353
        foreach ($blocks as $block) {
354
            $lines = preg_split('/\r?\n/', trim($block));
355
            $row = [];
356
            foreach ($lines as $line) {
357
                $kv = explode(' = ', $line, 2);
358
                if (count($kv) === 2) {
359
                    $row[$kv[0]] = $kv[1];
360
                }
361
            }
362
363
            if (empty($row['Path'])) {
364
                continue;
365
            }
366
367
            $attr = $row['Attributes'] ?? '';
368
            if (str_contains($attr, 'D')) {
369
                continue; // directory
370
            }
371
372
            $encrypted = ($row['Encrypted'] ?? '') === '+';
373
            if ($encrypted) {
374
                $anyEncrypted = true;
375
            }
376
377
            $size = isset($row['Size']) && ctype_digit($row['Size']) ? (int) $row['Size'] : 0;
378
            $files[] = ['name' => $row['Path'], 'size' => $size, 'encrypted' => $encrypted];
379
380
            if (count($files) >= 200) {
381
                break;
382
            }
383
        }
384
385
        if ($anyEncrypted && isset($files[0])) {
386
            $files[0]['__any_encrypted__'] = true;
387
        }
388
389
        return $files;
390
    }
391
392
    /**
393
     * Filter 7z files using extension whitelist.
394
     */
395
    private function filterSevenZipFiles(array $files): array
396
    {
397
        $allowedExtensions = $this->getAllowedExtensions();
398
        $filtered = [];
399
400
        foreach ($files as $entry) {
401
            if (! empty($entry['__any_encrypted__'])) {
402
                continue;
403
            }
404
405
            $name = $entry['name'] ?? '';
406
            if ($name === '' || strlen($name) > 300) {
407
                continue;
408
            }
409
410
            $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($name, App\Serv...ing\PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() does only seem to accept string, 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

410
            $ext = strtolower(/** @scrutinizer ignore-type */ pathinfo($name, PATHINFO_EXTENSION));
Loading history...
411
            if (! in_array($ext, $allowedExtensions, true)) {
412
                continue;
413
            }
414
415
            $base = pathinfo($name, PATHINFO_FILENAME);
416
            $letterCount = preg_match_all('/[a-z]/i', $base);
0 ignored issues
show
Bug introduced by
It seems like $base can also be of type array; however, parameter $subject of preg_match_all() does only seem to accept string, 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

416
            $letterCount = preg_match_all('/[a-z]/i', /** @scrutinizer ignore-type */ $base);
Loading history...
417
            if ($letterCount <= 5) {
418
                continue;
419
            }
420
421
            $filtered[] = [
422
                'name' => $name,
423
                'size' => $entry['size'] ?? 0,
424
                'date' => time(),
425
                'pass' => 0,
426
                'crc32' => '',
427
                'source' => '7z-list',
428
            ];
429
430
            if (count($filtered) >= 50) {
431
                break;
432
            }
433
        }
434
435
        return $filtered;
436
    }
437
438
    /**
439
     * Scan for filenames in 7z raw data.
440
     */
441
    private function scanSevenZipFilenames(string $data): array
442
    {
443
        $slice = substr($data, 0, 8 * 1024 * 1024);
444
        $converted = preg_replace('/([\x20-\x7E])\x00/', '$1', $slice) ?? $slice;
445
        $converted = str_replace("\x00", ' ', $converted);
446
447
        $exts = '7z|rar|zip|gz|bz2|xz|tar|tgz|mp4|mkv|avi|mpg|mpeg|mov|ts|wmv|flv|m4v|mp3|flac|ogg|wav|aac|aiff|ape|mka|nfo|txt|diz|pdf|epub|mobi|jpg|jpeg|png|gif|sfv|par2|exe|dll|srt|sub|idx|iso|bin|cue|mds|mdf';
448
        $regex = '~(?:[A-Za-z0-9 _.+&@#,()!-]{0,120}[\\/])?([A-Za-z0-9 _.+&@#,()!-]{2,160}\.(?:'.$exts.'))~i';
449
        preg_match_all($regex, $converted, $matches, PREG_SET_ORDER);
450
451
        if (empty($matches)) {
452
            return [];
453
        }
454
455
        $names = [];
456
        foreach ($matches as $match) {
457
            $candidate = preg_replace('/ {2,}/', ' ', $match[1]);
458
            if ($candidate === '' || strlen($candidate) < 5 || substr_count($candidate, '.') > 10) {
459
                continue;
460
            }
461
            $candidate = trim($candidate, " .-\t\n\r");
462
            $lower = strtolower($candidate);
463
            if (! isset($names[$lower])) {
464
                $names[$lower] = $candidate;
465
                if (count($names) >= 80) {
466
                    break;
467
                }
468
            }
469
        }
470
471
        return array_values($names);
472
    }
473
474
    /**
475
     * Extract using 7zip binary.
476
     */
477
    public function extractViaSevenZip(string $compressedData, string $type, string $tmpPath): array
478
    {
479
        $result = [
480
            'success' => false,
481
            'files' => [],
482
            'hasPassword' => false,
483
            'passwordStatus' => Releases::PASSWD_NONE,
484
        ];
485
486
        if ($this->config->extractUsingRarInfo || ! $this->config->sevenZipPath) {
487
            return $result;
488
        }
489
490
        try {
491
            $extMap = ['7z' => '7z', 'gzip' => 'gz', 'bzip2' => 'bz2', 'xz' => 'xz'];
492
            $markerMap = ['7z' => '7z', 'gzip' => 'g', 'bzip2' => 'b', 'xz' => 'x'];
493
            $ext = $extMap[$type] ?? 'dat';
494
            $marker = $markerMap[$type] ?? $type;
495
496
            $extractDir = $tmpPath.'un7z/'.uniqid('', true).'/';
497
            if (! File::isDirectory($extractDir)) {
498
                File::makeDirectory($extractDir, 0777, true, true);
499
            }
500
501
            $fileName = $tmpPath.uniqid('', true).'.'.$ext;
502
            File::put($fileName, $compressedData);
503
504
            $cmd = [$this->config->sevenZipPath, 'e', '-y', '-bd', '-o'.$extractDir, $fileName];
505
            $exitCode = 0;
506
            $stdout = null;
507
            $stderr = null;
508
            $this->execCommand($cmd, $exitCode, $stdout, $stderr);
509
510
            $files = [];
511
            if (File::isDirectory($extractDir)) {
512
                foreach (File::allFiles($extractDir) as $f) {
513
                    $files[] = [
514
                        'name' => $f->getFilename(),
515
                        'size' => $f->getSize(),
516
                        'date' => time(),
517
                        'pass' => 0,
518
                        'crc32' => '',
519
                        'source' => $type,
520
                    ];
521
                }
522
            }
523
524
            File::delete($fileName);
525
526
            if (! empty($files)) {
527
                return [
528
                    'success' => true,
529
                    'files' => $this->filterExtractedFiles($files),
530
                    'hasPassword' => false,
531
                    'passwordStatus' => Releases::PASSWD_NONE,
532
                    'archiveMarker' => $marker,
533
                ];
534
            }
535
        } catch (\Throwable $e) {
536
            if ($this->config->debugMode) {
537
                Log::warning(strtoupper($type).' extraction exception: '.$e->getMessage());
538
            }
539
        }
540
541
        return $result;
542
    }
543
544
    /**
545
     * Filter extracted files by allowed extensions.
546
     */
547
    private function filterExtractedFiles(array $files): array
548
    {
549
        $allowedExtensions = $this->getAllowedExtensions();
550
        $filtered = [];
551
552
        foreach ($files as $file) {
553
            $name = $file['name'] ?? '';
554
            $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($name, App\Serv...ing\PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() does only seem to accept string, 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

554
            $ext = strtolower(/** @scrutinizer ignore-type */ pathinfo($name, PATHINFO_EXTENSION));
Loading history...
555
556
            if (! in_array($ext, $allowedExtensions, true)) {
557
                continue;
558
            }
559
560
            $base = pathinfo($name, PATHINFO_FILENAME);
561
            $letterCount = preg_match_all('/[a-z]/i', $base);
0 ignored issues
show
Bug introduced by
It seems like $base can also be of type array; however, parameter $subject of preg_match_all() does only seem to accept string, 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

561
            $letterCount = preg_match_all('/[a-z]/i', /** @scrutinizer ignore-type */ $base);
Loading history...
562
            if ($letterCount <= 5) {
563
                continue;
564
            }
565
566
            $filtered[] = $file;
567
        }
568
569
        return $filtered;
570
    }
571
572
    /**
573
     * Get list of allowed file extensions.
574
     */
575
    private function getAllowedExtensions(): array
576
    {
577
        return ['nfo', 'srt', 'mkv', 'mpeg', 'avi', 'jpg', 'jpeg', 'exe', 'mp4', 'mp3', 'm4a',
578
            'flac', 'png', 'epub', 'cbz', 'cbr', 'djvu'];
579
    }
580
581
    /**
582
     * Prepare extraction directories.
583
     */
584
    private function prepareExtractionDirectories(string $tmpPath): void
585
    {
586
        if ($this->config->extractUsingRarInfo) {
587
            return;
588
        }
589
590
        try {
591
            if ($this->config->unrarPath) {
592
                $unrarDir = $tmpPath.'unrar/';
593
                if (! File::isDirectory($unrarDir)) {
594
                    File::makeDirectory($unrarDir, 0777, true, true);
595
                }
596
            }
597
            $unzipDir = $tmpPath.'unzip/';
598
            if (! File::isDirectory($unzipDir)) {
599
                File::makeDirectory($unzipDir, 0777, true, true);
600
            }
601
        } catch (\Throwable $e) {
602
            if ($this->config->debugMode) {
603
                Log::warning('Failed ensuring extraction subdirectories: '.$e->getMessage());
604
            }
605
        }
606
    }
607
608
    /**
609
     * Extract archive based on type.
610
     */
611
    private function extractArchive(string $compressedData, array $dataSummary, string $tmpPath): string
612
    {
613
        $killString = $this->config->getKillString();
614
615
        switch ($dataSummary['main_type']) {
616
            case ArchiveInfo::TYPE_RAR:
617
                if (! $this->config->extractUsingRarInfo && $this->config->unrarPath) {
618
                    $fileName = $tmpPath.uniqid('', true).'.rar';
619
                    File::put($fileName, $compressedData);
620
                    runCmd($killString.$this->config->unrarPath.'" e -ai -ep -c- -id -inul -kb -or -p- -r -y "'.$fileName.'" "'.$tmpPath.'unrar/"');
621
                    File::delete($fileName);
622
                }
623
                return 'r';
624
625
            case ArchiveInfo::TYPE_ZIP:
626
                if (! $this->config->extractUsingRarInfo && $this->config->unzipPath) {
627
                    $fileName = $tmpPath.uniqid('', true).'.zip';
628
                    File::put($fileName, $compressedData);
629
                    runCmd($this->config->unzipPath.' -o "'.$fileName.'" -d "'.$tmpPath.'unzip/"');
630
                    File::delete($fileName);
631
                }
632
                return 'z';
633
        }
634
635
        return '';
636
    }
637
638
    /**
639
     * Detect standalone video from binary data.
640
     */
641
    public function detectStandaloneVideo(string $data): ?string
642
    {
643
        $len = strlen($data);
644
        if ($len < 16) {
645
            return null;
646
        }
647
648
        // AVI
649
        if (strncmp($data, 'RIFF', 4) === 0 && substr($data, 8, 4) === 'AVI ') {
650
            return 'avi';
651
        }
652
        // Matroska / WebM
653
        if (strncmp($data, "\x1A\x45\xDF\xA3", 4) === 0) {
654
            return 'mkv';
655
        }
656
        // MPEG
657
        $sig4 = substr($data, 0, 4);
658
        if ($sig4 === "\x00\x00\x01\xBA" || $sig4 === "\x00\x00\x01\xB3") {
659
            return 'mpg';
660
        }
661
        // Transport Stream
662
        if ($len >= 188 * 5) {
663
            $isTs = true;
664
            for ($i = 0; $i < 5; $i++) {
665
                if (! isset($data[188 * $i]) || $data[188 * $i] !== "\x47") {
666
                    $isTs = false;
667
                    break;
668
                }
669
            }
670
            if ($isTs) {
671
                return 'mpg';
672
            }
673
        }
674
        // MP4/MOV
675
        if ($len >= 12 && substr($data, 4, 4) === 'ftyp') {
676
            $brands = ['isom', 'iso2', 'avc1', 'mp41', 'mp42', 'dash', 'MSNV', 'qt  ', 'M4V ', 'M4P ', 'M4B ', 'M4A '];
677
            if (in_array(substr($data, 8, 4), $brands, true)) {
678
                return 'mp4';
679
            }
680
        }
681
682
        return null;
683
    }
684
685
    /**
686
     * Get PAR2 info parser.
687
     */
688
    public function getPar2Info(): Par2Info
689
    {
690
        return $this->par2Info;
691
    }
692
693
    /**
694
     * Get archive info handler.
695
     */
696
    public function getArchiveInfo(): ArchiveInfo
697
    {
698
        return $this->archiveInfo;
699
    }
700
701
    /**
702
     * Execute a command with output capture.
703
     */
704
    private function execCommand(array $cmd, ?int &$exitCode, ?string &$stdout, ?string &$stderr): bool
705
    {
706
        $descriptorSpec = [
707
            1 => ['pipe', 'w'],
708
            2 => ['pipe', 'w'],
709
        ];
710
711
        $process = @proc_open($cmd, $descriptorSpec, $pipes, null, null, ['bypass_shell' => true]);
712
        if (! is_resource($process)) {
713
            $exitCode = -1;
714
            return false;
715
        }
716
717
        $stdout = stream_get_contents($pipes[1]);
718
        fclose($pipes[1]);
719
        $stderr = stream_get_contents($pipes[2]);
720
        fclose($pipes[2]);
721
        $exitCode = proc_close($process);
722
723
        return $exitCode === 0;
724
    }
725
}
726
727