Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
MediaModerationFileScanner
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
2 / 2
11
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 scanSha1
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
10
1<?php
2
3namespace MediaWiki\Extension\MediaModeration\Services;
4
5use MediaWiki\Extension\MediaModeration\PhotoDNA\IMediaModerationPhotoDNAServiceProvider;
6use MediaWiki\Extension\MediaModeration\PhotoDNA\Response;
7use MediaWiki\Language\RawMessage;
8use MediaWiki\Status\StatusFormatter;
9use MediaWiki\WikiMap\WikiMap;
10use Psr\Log\LoggerInterface;
11use StatusValue;
12use Wikimedia\Stats\StatsFactory;
13
14/**
15 * Scans SHA-1 values in batches to reduce the effect that slow requests to
16 * PhotoDNA have on the overall speed of scanning.
17 */
18class MediaModerationFileScanner {
19
20    private MediaModerationDatabaseManager $mediaModerationDatabaseManager;
21    private MediaModerationDatabaseLookup $mediaModerationDatabaseLookup;
22    private MediaModerationFileLookup $mediaModerationFileLookup;
23    private MediaModerationFileProcessor $mediaModerationFileProcessor;
24    private IMediaModerationPhotoDNAServiceProvider $mediaModerationPhotoDNAServiceProvider;
25    private MediaModerationEmailer $mediaModerationEmailer;
26    private StatsFactory $statsFactory;
27    private StatusFormatter $statusFormatter;
28    private LoggerInterface $logger;
29
30    public function __construct(
31        MediaModerationDatabaseLookup $mediaModerationDatabaseLookup,
32        MediaModerationDatabaseManager $mediaModerationDatabaseManager,
33        MediaModerationFileLookup $mediaModerationFileLookup,
34        MediaModerationFileProcessor $mediaModerationFileProcessor,
35        IMediaModerationPhotoDNAServiceProvider $mediaModerationPhotoDNAServiceProvider,
36        MediaModerationEmailer $mediaModerationEmailer,
37        StatusFormatter $statusFormatter,
38        StatsFactory $statsFactory,
39        LoggerInterface $logger
40    ) {
41        $this->mediaModerationDatabaseLookup = $mediaModerationDatabaseLookup;
42        $this->mediaModerationDatabaseManager = $mediaModerationDatabaseManager;
43        $this->mediaModerationFileLookup = $mediaModerationFileLookup;
44        $this->mediaModerationFileProcessor = $mediaModerationFileProcessor;
45        $this->mediaModerationPhotoDNAServiceProvider = $mediaModerationPhotoDNAServiceProvider;
46        $this->mediaModerationEmailer = $mediaModerationEmailer;
47        $this->statusFormatter = $statusFormatter;
48        $this->statsFactory = $statsFactory;
49        $this->logger = $logger;
50    }
51
52    /**
53     * Scans the files that have the given SHA-1
54     *
55     * @param string $sha1
56     * @return StatusValue
57     */
58    public function scanSha1( string $sha1 ): StatusValue {
59        $wiki = WikiMap::getCurrentWikiId();
60        $returnStatus = new StatusValue();
61        // Until a match is got from PhotoDNA, the return status should be not okay as the operation has not completed.
62        $returnStatus->setOK( false );
63        // Get the current scan status from the DB, so that we keep the current value if
64        // nothing matches but still update the last checked value.
65        $oldMatchStatus = $this->mediaModerationDatabaseLookup->getMatchStatusForSha1( $sha1 );
66        $newMatchStatus = null;
67        foreach ( $this->mediaModerationFileLookup->getFileObjectsForSha1( $sha1 ) as $file ) {
68            if ( !$this->mediaModerationFileProcessor->canScanFile( $file ) ) {
69                // If this $file cannot be scanned, then try the next file with this SHA-1
70                // and if in verbose mode output to the console about this.
71                $this->statsFactory->withComponent( 'MediaModeration' )
72                    ->getCounter( 'file_scanner_found_unscannable_file_total' )
73                    ->setLabel( 'wiki', $wiki )
74                    ->copyToStatsdAt( "$wiki.MediaModeration.FileScanner.CanScanFileReturnedFalse" )
75                    ->increment();
76                $returnStatus->fatal( new RawMessage( "The file {$file->getName()} cannot be scanned." ) );
77                continue;
78            }
79            // Run the check using the PhotoDNA API.
80            $checkResult = $this->mediaModerationPhotoDNAServiceProvider->check( $file );
81            /** @var Response|null $response */
82            $response = $checkResult->getValue();
83            if ( $response === null || $response->getStatusCode() !== Response::STATUS_OK ) {
84                // Assume something is wrong with the thumbnail or source file if the request fails,
85                // and just try a new $file with this SHA-1. Add the information about the
86                // failure to the return status for tracking and logging.
87                $returnStatus->merge( $checkResult );
88                continue;
89            }
90            $newMatchStatus = $response->isMatch();
91            // Stop processing this SHA-1 as we have a result.
92            break;
93        }
94        // Update the match status, even if none of the $file objects could be scanned.
95        // If no scanning was successful, then the status will remain
96        $this->mediaModerationDatabaseManager->updateMatchStatusForSha1( $sha1, $newMatchStatus ?? $oldMatchStatus );
97        if ( $newMatchStatus && $newMatchStatus !== $oldMatchStatus ) {
98            // Send an email for this SHA-1 if the match status has changed to positive.
99            // If the match status was already positive, then an email has already been sent.
100            $this->mediaModerationEmailer->sendEmailForSha1( $sha1 );
101        }
102        if ( $newMatchStatus !== null ) {
103            $returnStatus->setResult( true, $newMatchStatus );
104        }
105        if ( !$returnStatus->isOK() ) {
106            // Create a info if the SHA-1 could not be scanned.
107            $this->logger->info(
108                'Unable to scan SHA-1 {sha1}. MediaModerationFileScanner::scanSha1 returned this: {return-message}',
109                [
110                    'sha1' => $sha1,
111                    'return-message' => $this->statusFormatter->getMessage( $returnStatus, [ 'lang' => 'en' ] ),
112                ]
113            );
114        } elseif ( !$returnStatus->isGood() ) {
115            // Create a debug if the SHA-1 scanning succeeded with warnings.
116            $this->logger->debug(
117                'Scan of SHA-1 {sha1} succeeded with warnings. MediaModerationFileScanner::scanSha1 ' .
118                'returned this: {return-message}',
119                [
120                    'sha1' => $sha1,
121                    'return-message' => $this->statusFormatter->getMessage( $returnStatus, [ 'lang' => 'en' ] ),
122                ]
123            );
124        }
125        return $returnStatus;
126    }
127}